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
36 changes: 29 additions & 7 deletions src/google/adk/planners/plan_re_act_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions tests/unittests/planners/test_plan_re_act_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down