Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/google/adk/planners/base_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import abc
from abc import ABC
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

Expand Down Expand Up @@ -66,3 +68,34 @@ def process_planning_response(
The processed response parts, or None if no processing is needed.
"""
pass

def to_content_blocks(
self, parts: List[types.Part]
) -> List[Dict[str, Any]]:
"""Converts response parts to standardized content blocks.

Produces a list of dicts with a ``type`` key, following the standard
content block convention used by LangChain and similar frameworks.
Parts marked as thoughts are emitted as
``{'type': 'reasoning', 'reasoning': '<text>'}``; plain text parts are
emitted as ``{'type': 'text', 'text': '<text>'}``; function-call parts
are skipped.

This method works uniformly for both :class:`PlanReActPlanner` (where
reasoning is identified by the ``thought`` flag) and
:class:`BuiltInPlanner` (where the model sets ``thought=True`` natively).

Args:
parts: The response parts to convert, typically the return value of
:meth:`process_planning_response`.

Returns:
A list of standardized content block dicts.
"""
blocks: List[Dict[str, Any]] = []
for part in parts:
if part.thought and part.text:
blocks.append({'type': 'reasoning', 'reasoning': part.text})
elif part.text and not part.thought:
blocks.append({'type': 'text', 'text': part.text})
return blocks
20 changes: 19 additions & 1 deletion src/google/adk/planners/plan_re_act_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,32 @@ def _handle_non_function_call_parts(
self._mark_as_thought(response_part)
preserved_parts.append(response_part)

def _strip_planning_tags(self, text: str) -> str:
"""Strips leading planning/reasoning/action/replanning tags from text.

Args:
text: The text to strip.

Returns:
The text with the leading tag removed (if present).
"""
for tag in [PLANNING_TAG, REPLANNING_TAG, REASONING_TAG, ACTION_TAG]:
if text.startswith(tag):
return text[len(tag):]
return text

def _mark_as_thought(self, response_part: types.Part) -> None:
"""Marks the response part as thought.
"""Marks the response part as thought and strips any leading planning tag.

The raw tags (e.g. ``/*PLANNING*/``, ``/*REASONING*/``) are removed so
that consumers can read the plain reasoning text without further parsing.

Args:
response_part: The mutable response part to mark as thought.
"""
if response_part.text:
response_part.thought = True
response_part.text = self._strip_planning_tags(response_part.text)
return

def _build_nl_planner_instruction(self) -> str:
Expand Down
57 changes: 57 additions & 0 deletions tests/unittests/planners/test_plan_re_act_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

"""Tests for PlanReActPlanner.process_planning_response."""

from google.adk.planners.plan_re_act_planner import ACTION_TAG
from google.adk.planners.plan_re_act_planner import FINAL_ANSWER_TAG
from google.adk.planners.plan_re_act_planner import PLANNING_TAG
from google.adk.planners.plan_re_act_planner import REASONING_TAG
from google.adk.planners.plan_re_act_planner import PlanReActPlanner
from google.genai import types

Expand Down Expand Up @@ -56,3 +60,56 @@ def test_preserves_parallel_function_calls_after_leading_text():
)

assert _function_call_names(result) == ["get_weather", "get_time"]


# ---------------------------------------------------------------------------
# to_content_blocks() standardized output
# ---------------------------------------------------------------------------


def test_to_content_blocks_tags_stripped_from_thought_parts():
"""Planning tags must be stripped and thought parts emitted as 'reasoning'."""
planner = PlanReActPlanner()
answer_text = "The final answer is 42."

response_parts = [
types.Part(text=f"{PLANNING_TAG}Step 1: call search tool."),
types.Part(text=f"{REASONING_TAG}Analysing results."),
types.Part(text=f"{ACTION_TAG}Calling tool now."),
types.Part(text=f"{FINAL_ANSWER_TAG}{answer_text}"),
]

processed = planner.process_planning_response(
callback_context=None, response_parts=response_parts
)
assert processed is not None

blocks = planner.to_content_blocks(processed)

reasoning_blocks = [b for b in blocks if b["type"] == "reasoning"]
text_blocks = [b for b in blocks if b["type"] == "text"]

# All thought parts must surface as reasoning blocks with no raw tags.
assert len(reasoning_blocks) >= 1
for rb in reasoning_blocks:
for tag in [PLANNING_TAG, REASONING_TAG, ACTION_TAG, FINAL_ANSWER_TAG]:
assert tag not in rb["reasoning"]

# The final answer must become a plain text block.
assert len(text_blocks) == 1
assert text_blocks[0]["text"] == answer_text


def test_to_content_blocks_type_keys_present():
"""Every content block must carry a 'type' key."""
planner = PlanReActPlanner()
parts = [
types.Part(text=f"{PLANNING_TAG}my plan"),
types.Part(text=f"{FINAL_ANSWER_TAG}my answer"),
]
processed = planner.process_planning_response(
callback_context=None, response_parts=parts
)
assert processed is not None
blocks = planner.to_content_blocks(processed)
assert all("type" in b for b in blocks)