From c2e2e28b72546e74b3d28354665938fd68cb1ce5 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Sun, 9 Aug 2026 09:15:45 +0000 Subject: [PATCH] Strip structural tags from PlanReActPlanner response parts `PlanReActPlanner.process_planning_response` was leaving raw structural markers Signed-off-by: Ishaan --- .../adk/planners/plan_re_act_planner.py | 38 +++++++++++-- .../planners/test_plan_re_act_planner.py | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/google/adk/planners/plan_re_act_planner.py b/src/google/adk/planners/plan_re_act_planner.py index d3fd4535a9c..595f94c5ddf 100644 --- a/src/google/adk/planners/plan_re_act_planner.py +++ b/src/google/adk/planners/plan_re_act_planner.py @@ -102,6 +102,20 @@ def _split_by_last_pattern( return text, '' return text[: index + len(separator)], text[index + len(separator) :] + def _strip_tag(self, text: str, tag: str) -> str: + """Strips the leading tag from the text if present. + + Args: + text: The text to strip the tag from. + tag: The tag to strip. + + Returns: + The text with the leading tag stripped. + """ + if text.startswith(tag): + return text[len(tag):] + return text + def _handle_non_function_call_parts( self, response_part: types.Part, preserved_parts: list[types.Part] ) -> None: @@ -116,6 +130,13 @@ def _handle_non_function_call_parts( reasoning_text, final_answer_text = self._split_by_last_pattern( response_part.text, FINAL_ANSWER_TAG ) + # _split_by_last_pattern includes the separator in the left side; strip + # it along with any leading structural tag so consumers receive clean + # text without needing to parse the markers. + if reasoning_text.endswith(FINAL_ANSWER_TAG): + reasoning_text = reasoning_text[: -len(FINAL_ANSWER_TAG)] + for tag in [PLANNING_TAG, REASONING_TAG, ACTION_TAG, REPLANNING_TAG]: + reasoning_text = self._strip_tag(reasoning_text, tag) if reasoning_text: reasoning_part = types.Part(text=reasoning_text) self._mark_as_thought(reasoning_part) @@ -129,18 +150,23 @@ def _handle_non_function_call_parts( else: response_text = response_part.text or '' # If the part is a text part with a planning/reasoning/action tag, - # label it as reasoning. - if response_text and ( - any( - response_text.startswith(tag) + # label it as reasoning and strip the leading tag so consumers receive + # clean text without needing to parse the structural markers. + matched_tag = next( + ( + tag for tag in [ PLANNING_TAG, REASONING_TAG, ACTION_TAG, REPLANNING_TAG, ] - ) - ): + if response_text.startswith(tag) + ), + None, + ) + if response_text and matched_tag: + response_part.text = self._strip_tag(response_text, matched_tag) self._mark_as_thought(response_part) preserved_parts.append(response_part) diff --git a/tests/unittests/planners/test_plan_re_act_planner.py b/tests/unittests/planners/test_plan_re_act_planner.py index ccafdf48a99..8d04acc9286 100644 --- a/tests/unittests/planners/test_plan_re_act_planner.py +++ b/tests/unittests/planners/test_plan_re_act_planner.py @@ -56,3 +56,59 @@ def test_preserves_parallel_function_calls_after_leading_text(): ) assert _function_call_names(result) == ["get_weather", "get_time"] + + +def test_structural_tags_stripped_from_thought_parts(): + """Planning/reasoning/action tags must be removed from the part text. + + The structural markers are implementation details; consumers should receive + clean text and use ``part.thought`` to distinguish reasoning from the final + answer. + """ + planner = PlanReActPlanner() + response_parts = [ + types.Part(text="/*PLANNING*/ Step 1: look up the weather."), + types.Part(text="/*REASONING*/ Based on the results, the answer is 42."), + types.Part(text="/*ACTION*/ Calling tool now."), + types.Part(text="/*REPLANNING*/ Revising plan after failure."), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + for part in result: + assert part.thought is True + assert not any( + part.text.startswith(tag) + for tag in [ + "/*PLANNING*/", + "/*REASONING*/", + "/*ACTION*/", + "/*REPLANNING*/", + ] + ), f"Tag not stripped from: {part.text!r}" + + +def test_final_answer_tag_stripped_from_reasoning_and_answer(): + """FINAL_ANSWER tag must be absent from both the thought and answer parts.""" + planner = PlanReActPlanner() + response_parts = [ + types.Part( + text="/*PLANNING*/ My plan./*FINAL_ANSWER*/ The answer is 42." + ), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + thought_parts = [p for p in result if p.thought] + answer_parts = [p for p in result if not p.thought and not p.function_call] + + assert len(thought_parts) == 1 + assert "/*PLANNING*/" not in thought_parts[0].text + assert "/*FINAL_ANSWER*/" not in thought_parts[0].text + + assert len(answer_parts) == 1 + assert answer_parts[0].text == " The answer is 42."