From 5ca2394c17e13356d80f532766412ed49bab88a7 Mon Sep 17 00:00:00 2001 From: Ishaan Date: Thu, 13 Aug 2026 09:05:38 +0000 Subject: [PATCH] Strip internal planning tags from PlanReActPlanner output content blocks `PlanReActPlanner.process_planning_response` returned raw text that still contained the internal `/*PLANNING*/`, `/*REASONING*/`, `/*ACTION*/`, `/*REPLANNING*/`, and `/*FINAL_ANSWER*/` markers, forcing callers to parse those artefacts themselves. Signed-off-by: Ishaan --- .../adk/planners/plan_re_act_planner.py | 36 ++++++++++--- .../planners/test_plan_re_act_planner.py | 51 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 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..3148efb4b9b 100644 --- a/src/google/adk/planners/plan_re_act_planner.py +++ b/src/google/adk/planners/plan_re_act_planner.py @@ -102,11 +102,31 @@ def _split_by_last_pattern( return text, '' return text[: index + len(separator)], text[index + len(separator) :] + _PLANNING_TAGS = (PLANNING_TAG, REASONING_TAG, ACTION_TAG, REPLANNING_TAG) + + def _strip_leading_planning_tag(self, text: str) -> str: + """Strips a leading planning tag from the text, if present. + + Args: + text: The text to strip. + + Returns: + The text with the leading planning tag removed. + """ + for tag in self._PLANNING_TAGS: + 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: """Handles non-function-call parts of the response. + The method strips embedded planning tags (e.g. ``/*PLANNING*/``, + ``/*REASONING*/``) from the text so that callers receive clean content + blocks instead of raw tagged text. + Args: response_part: The response part to handle. preserved_parts: The mutable list of parts to store the processed parts @@ -116,6 +136,11 @@ 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 part; strip + # it so the reasoning block contains only the actual reasoning text. + if reasoning_text.endswith(FINAL_ANSWER_TAG): + reasoning_text = reasoning_text[: -len(FINAL_ANSWER_TAG)] + reasoning_text = self._strip_leading_planning_tag(reasoning_text) if reasoning_text: reasoning_part = types.Part(text=reasoning_text) self._mark_as_thought(reasoning_part) @@ -129,18 +154,15 @@ 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. + # label it as reasoning and strip the tag to produce clean text. if response_text and ( any( response_text.startswith(tag) - for tag in [ - PLANNING_TAG, - REASONING_TAG, - ACTION_TAG, - REPLANNING_TAG, - ] + for tag in self._PLANNING_TAGS ) ): + clean_text = self._strip_leading_planning_tag(response_text) + response_part = types.Part(text=clean_text) 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..a2c7f71aa80 100644 --- a/tests/unittests/planners/test_plan_re_act_planner.py +++ b/tests/unittests/planners/test_plan_re_act_planner.py @@ -22,6 +22,57 @@ def _function_call_names(parts): return [p.function_call.name for p in parts if p.function_call] +def test_strips_planning_tag_from_thought_part(): + """Planning/reasoning tags must be stripped from the output text. + + The raw ``/*PLANNING*/``, ``/*REASONING*/``, ``/*ACTION*/`` and + ``/*REPLANNING*/`` markers are internal prompting artefacts. After + processing, the resulting parts should contain clean text and have + ``thought=True`` set. + """ + planner = PlanReActPlanner() + response_parts = [ + types.Part(text='/*PLANNING*/Step 1: look it up.'), + types.Part(text='/*REASONING*/I need to call the tool.'), + types.Part.from_function_call(name='lookup', args={'q': 'test'}), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + text_parts = [p for p in result if p.text] + # Tags must be gone + for p in text_parts: + assert '/*PLANNING*/' not in p.text + assert '/*REASONING*/' not in p.text + # Thought flag must be set on non-final-answer text parts + for p in text_parts: + assert p.thought is True + # Function call must still be present + assert _function_call_names(result) == ['lookup'] + + +def test_strips_final_answer_tag_boundary(): + """The /*FINAL_ANSWER*/ tag must not appear in either output block.""" + planner = PlanReActPlanner() + response_parts = [ + types.Part( + text='/*REASONING*/Some reasoning./*FINAL_ANSWER*/The answer is 42.' + ), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + texts = [p.text for p in result if p.text] + combined = ' '.join(texts) + assert '/*FINAL_ANSWER*/' not in combined + assert '/*REASONING*/' not in combined + assert 'The answer is 42.' in combined + + def test_preserves_all_leading_parallel_function_calls(): """Parallel function calls at the start of the response must all survive.