From 01419f1869e61a2e1732f0b3046ec35059e75ad1 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Mon, 10 Aug 2026 09:14:36 +0000 Subject: [PATCH] Standardize planner output as typed content blocks for PlanReActPlanner and BuiltInPlanner Both `PlanReActPlanner` and `BuiltInPlanner` expose reasoning through Signed-off-by: Ishaan --- src/google/adk/planners/base_planner.py | 33 +++++++++++ .../adk/planners/plan_re_act_planner.py | 20 ++++++- .../planners/test_plan_re_act_planner.py | 57 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/google/adk/planners/base_planner.py b/src/google/adk/planners/base_planner.py index 05ac2ca3bc9..9971216f9a4 100644 --- a/src/google/adk/planners/base_planner.py +++ b/src/google/adk/planners/base_planner.py @@ -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 @@ -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': ''}``; plain text parts are + emitted as ``{'type': '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 diff --git a/src/google/adk/planners/plan_re_act_planner.py b/src/google/adk/planners/plan_re_act_planner.py index d3fd4535a9c..6860b42ce55 100644 --- a/src/google/adk/planners/plan_re_act_planner.py +++ b/src/google/adk/planners/plan_re_act_planner.py @@ -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: diff --git a/tests/unittests/planners/test_plan_re_act_planner.py b/tests/unittests/planners/test_plan_re_act_planner.py index ccafdf48a99..8a403f380d4 100644 --- a/tests/unittests/planners/test_plan_re_act_planner.py +++ b/tests/unittests/planners/test_plan_re_act_planner.py @@ -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 @@ -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)