From c653030c9b888deae70e0ea687a124c790086617 Mon Sep 17 00:00:00 2001 From: vaibhav-patel Date: Mon, 22 Jun 2026 15:00:45 +0530 Subject: [PATCH 1/2] feat(planners): add standardized structured content for planner output PlanReActPlanner and BuiltInPlanner emit their output as a list of google.genai Part objects where reasoning is signalled only by ``thought=True`` and, for PlanReActPlanner, by inline ``/*PLANNING*/`` style tags embedded in the text. Consumers that want a structured view of the plan/reasoning/answer have to re-parse that raw text themselves. Add a small, additive, read-only converter that turns planner-produced parts into a provider-agnostic list of typed content blocks modelled after LangChain v1's standard content blocks: [{'type': 'reasoning', 'reasoning': ..., 'reasoning_kind': ...}, {'type': 'tool_call', 'name': ..., 'args': ..., 'id': ...}, {'type': 'text', 'text': ...}] The new ``planner_content_blocks`` module exposes ``parts_to_content_blocks`` / ``part_to_content_block`` plus the block TypedDicts, and both planners gain a ``to_content_blocks`` convenience method. The conversion never mutates the parts and does not change ``build_planning_instruction`` or ``process_planning_response``, so existing Part-based consumers and the NL planning flow are unaffected. Fixes #3378. --- src/google/adk/planners/__init__.py | 12 + src/google/adk/planners/built_in_planner.py | 29 ++ .../adk/planners/plan_re_act_planner.py | 30 ++ .../adk/planners/planner_content_blocks.py | 220 +++++++++++++ .../planners/test_planner_content_blocks.py | 289 ++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 src/google/adk/planners/planner_content_blocks.py create mode 100644 tests/unittests/planners/test_planner_content_blocks.py diff --git a/src/google/adk/planners/__init__.py b/src/google/adk/planners/__init__.py index a479f7d4b1e..d61ca729bbd 100644 --- a/src/google/adk/planners/__init__.py +++ b/src/google/adk/planners/__init__.py @@ -15,9 +15,21 @@ from .base_planner import BasePlanner from .built_in_planner import BuiltInPlanner from .plan_re_act_planner import PlanReActPlanner +from .planner_content_blocks import ContentBlock +from .planner_content_blocks import parts_to_content_blocks +from .planner_content_blocks import part_to_content_block +from .planner_content_blocks import ReasoningContentBlock +from .planner_content_blocks import TextContentBlock +from .planner_content_blocks import ToolCallContentBlock __all__ = [ 'BasePlanner', 'BuiltInPlanner', + 'ContentBlock', 'PlanReActPlanner', + 'ReasoningContentBlock', + 'TextContentBlock', + 'ToolCallContentBlock', + 'part_to_content_block', + 'parts_to_content_blocks', ] diff --git a/src/google/adk/planners/built_in_planner.py b/src/google/adk/planners/built_in_planner.py index eb665263405..430e3e5e2c7 100644 --- a/src/google/adk/planners/built_in_planner.py +++ b/src/google/adk/planners/built_in_planner.py @@ -25,6 +25,8 @@ from ..agents.readonly_context import ReadonlyContext from ..models.llm_request import LlmRequest from .base_planner import BasePlanner +from .planner_content_blocks import ContentBlock +from .planner_content_blocks import parts_to_content_blocks logger = logging.getLogger('google_adk.' + __name__) @@ -84,3 +86,30 @@ def process_planning_response( response_parts: List[types.Part], ) -> Optional[List[types.Part]]: return + + def to_content_blocks( + self, + response_parts: List[types.Part], + ) -> List[ContentBlock]: + """Returns a standardized, structured view of the model response. + + The built-in planner relies on the model's native thinking, which surfaces + as parts with ``thought=True``. This converts the response parts into a + provider-agnostic list of content blocks modelled after LangChain v1's + standard content blocks, e.g.:: + + [{'type': 'reasoning', 'reasoning': '...', 'reasoning_kind': None}, + {'type': 'text', 'text': '...'}] + + This lets consumers branch on a typed ``type`` discriminator instead of + inspecting ``part.thought`` and the raw text. The conversion is read-only + and does not mutate ``response_parts``. + + Args: + response_parts: The model response parts. + + Returns: + A list of standardized content blocks. See + :mod:`google.adk.planners.planner_content_blocks`. + """ + return parts_to_content_blocks(response_parts) diff --git a/src/google/adk/planners/plan_re_act_planner.py b/src/google/adk/planners/plan_re_act_planner.py index 48ca41bb21e..52e5ff0b99f 100644 --- a/src/google/adk/planners/plan_re_act_planner.py +++ b/src/google/adk/planners/plan_re_act_planner.py @@ -24,6 +24,8 @@ from ..agents.readonly_context import ReadonlyContext from ..models.llm_request import LlmRequest from .base_planner import BasePlanner +from .planner_content_blocks import ContentBlock +from .planner_content_blocks import parts_to_content_blocks PLANNING_TAG = '/*PLANNING*/' REPLANNING_TAG = '/*REPLANNING*/' @@ -82,6 +84,34 @@ def process_planning_response( return preserved_parts + def to_content_blocks( + self, + response_parts: List[types.Part], + ) -> List[ContentBlock]: + """Returns a standardized, structured view of the planner output. + + Converts the parts produced by this planner (the output of + ``process_planning_response``, or any equivalent list of parts) into a + provider-agnostic list of content blocks modelled after LangChain v1's + standard content blocks, e.g.:: + + [{'type': 'reasoning', 'reasoning': '...', 'reasoning_kind': 'planning'}, + {'type': 'tool_call', 'name': '...', 'args': {...}, 'id': None}, + {'type': 'text', 'text': '...'}] + + This lets consumers branch on a typed ``type`` discriminator instead of + re-parsing the raw text and inline ``/*PLANNING*/`` style tags. The + conversion is read-only and does not mutate ``response_parts``. + + Args: + response_parts: The planner-produced parts. + + Returns: + A list of standardized content blocks. See + :mod:`google.adk.planners.planner_content_blocks`. + """ + return parts_to_content_blocks(response_parts) + def _split_by_last_pattern(self, text, separator): """Splits the text by the last occurrence of the separator. diff --git a/src/google/adk/planners/planner_content_blocks.py b/src/google/adk/planners/planner_content_blocks.py new file mode 100644 index 00000000000..1aca82d413d --- /dev/null +++ b/src/google/adk/planners/planner_content_blocks.py @@ -0,0 +1,220 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Standardized content blocks for planner output. + +Planners such as +:class:`~google.adk.planners.plan_re_act_planner.PlanReActPlanner` and +:class:`~google.adk.planners.built_in_planner.BuiltInPlanner` produce their +output as a list of ``google.genai.types.Part`` objects. Reasoning is signalled +on those parts with ``thought=True`` and, for ``PlanReActPlanner``, the text is +additionally annotated with inline ``/*PLANNING*/`` style tags that callers have +to parse themselves. + +This module provides a small, additive helper that converts that ``Part`` list +into a provider-agnostic, structured representation modelled after LangChain +v1's standard content blocks +(https://docs.langchain.com/oss/python/langchain/messages#standard-content-blocks), +so that consumers can branch on a typed ``type`` discriminator instead of +re-parsing raw text. + +The helper is read-only: it never mutates the parts it is given and it does not +change anything about how planners build instructions or post-process responses. +Existing consumers that rely on the ``Part`` output are unaffected. +""" + +from __future__ import annotations + +from typing import Any +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional + +from google.genai import types +from typing_extensions import TypedDict + + +# Inline tags emitted by ``PlanReActPlanner``. Kept in sync with +# ``plan_re_act_planner`` but duplicated here to avoid a circular import and to +# keep this module self-contained. +_PLANNING_TAG = '/*PLANNING*/' +_REPLANNING_TAG = '/*REPLANNING*/' +_REASONING_TAG = '/*REASONING*/' +_ACTION_TAG = '/*ACTION*/' +_FINAL_ANSWER_TAG = '/*FINAL_ANSWER*/' + +# Maps a leading PlanReActPlanner tag to the fine-grained reasoning kind exposed +# on reasoning content blocks. +_TAG_TO_REASONING_KIND: Dict[str, str] = { + _PLANNING_TAG: 'planning', + _REPLANNING_TAG: 'replanning', + _REASONING_TAG: 'reasoning', + _ACTION_TAG: 'action', +} + +ReasoningKind = Literal['planning', 'replanning', 'reasoning', 'action'] +"""Fine-grained category of a reasoning block. + +For ``PlanReActPlanner`` this is derived from the inline tag that prefixes the +text (``/*PLANNING*/`` -> ``planning`` and so on). For ``BuiltInPlanner`` and +for thought parts without a recognized tag it is ``None``. +""" + + +class ReasoningContentBlock(TypedDict, total=False): + """A reasoning (a.k.a. thought) content block. + + Modelled after LangChain v1's ``reasoning`` content block. ``type`` is always + ``"reasoning"`` and ``reasoning`` carries the reasoning text. + """ + + type: Literal['reasoning'] + reasoning: str + # ADK-specific, optional: which PlanReActPlanner phase this reasoning belongs + # to, when it can be determined. ``None`` for built-in thinking. + reasoning_kind: Optional[ReasoningKind] + + +class TextContentBlock(TypedDict, total=False): + """A plain text content block (e.g. a planner's final answer). + + Modelled after LangChain v1's ``text`` content block. + """ + + type: Literal['text'] + text: str + + +class ToolCallContentBlock(TypedDict, total=False): + """A tool/function call content block. + + Modelled after LangChain v1's ``tool_call`` content block. Carries the + function call requested by the planner. + """ + + type: Literal['tool_call'] + name: str + args: Dict[str, Any] + # Provider-assigned call id, when present on the part. + id: Optional[str] + + +# A standardized planner content block. New block ``type``s may be added in the +# future, so consumers should treat unknown types defensively. +ContentBlock = Dict[str, Any] + + +def _strip_leading_tag(text: str) -> tuple[str, Optional[str]]: + """Splits a recognized leading PlanReActPlanner tag off ``text``. + + Args: + text: The (already thought-marked) reasoning text. + + Returns: + A ``(body, reasoning_kind)`` tuple. ``body`` is ``text`` with a single + recognized leading tag removed and surrounding whitespace stripped; + ``reasoning_kind`` is the matching kind, or ``None`` when no recognized tag + prefixes the text. + """ + stripped = text.lstrip() + for tag, kind in _TAG_TO_REASONING_KIND.items(): + if stripped.startswith(tag): + return stripped[len(tag):].strip(), kind + return text, None + + +def part_to_content_block(part: types.Part) -> Optional[ContentBlock]: + """Converts a single ``Part`` into a standardized content block. + + Args: + part: The planner-produced part. Not mutated. + + Returns: + A standardized content block, or ``None`` if the part carries no content + that maps to a block (e.g. an empty part, or a redacted thought that only + carries a signature). + """ + # Function/tool calls take precedence over any incidental text on the part. + if part.function_call and part.function_call.name: + fc = part.function_call + block: ToolCallContentBlock = { + 'type': 'tool_call', + 'name': fc.name, + 'args': dict(fc.args) if fc.args else {}, + 'id': fc.id, + } + return block + + # Only text parts produce reasoning/text blocks. A thought part with no text + # (e.g. an Anthropic redacted-thinking part that only carries a signature) + # has no displayable content, so it is skipped. + if not part.text: + return None + + if part.thought: + body, kind = _strip_leading_tag(part.text) + # PlanReActPlanner splits a reasoning/final-answer part on the *last* + # ``/*FINAL_ANSWER*/`` and keeps that separator on the trailing edge of the + # reasoning part. It is a pure marker the planner already acted on, so drop + # it from the standardized reasoning text. + if body.endswith(_FINAL_ANSWER_TAG): + body = body[: -len(_FINAL_ANSWER_TAG)].rstrip() + reasoning_block: ReasoningContentBlock = { + 'type': 'reasoning', + 'reasoning': body, + 'reasoning_kind': kind, + } + return reasoning_block + + # A non-thought text part is final/answer text. PlanReActPlanner leaves the + # ``/*FINAL_ANSWER*/`` tag out of this part already, but strip a stray leading + # one defensively so the standardized block is clean. + text = part.text + stripped = text.lstrip() + if stripped.startswith(_FINAL_ANSWER_TAG): + text = stripped[len(_FINAL_ANSWER_TAG):].strip() + text_block: TextContentBlock = {'type': 'text', 'text': text} + return text_block + + +def parts_to_content_blocks( + parts: Optional[List[types.Part]], +) -> List[ContentBlock]: + """Converts planner-produced parts into standardized content blocks. + + This is the inverse-facing counterpart to a planner's + ``process_planning_response``: given the parts a planner produced (where + reasoning is signalled by ``thought=True`` and, for ``PlanReActPlanner``, by + inline tags), it returns a provider-agnostic list of typed content blocks. + + The conversion is read-only and never mutates ``parts``. Parts that carry no + mappable content are skipped, so the returned list may be shorter than + ``parts``. + + Args: + parts: The planner-produced parts, or ``None``. + + Returns: + A list of standardized content blocks. Empty when ``parts`` is falsy or + contains nothing mappable. + """ + if not parts: + return [] + blocks: List[ContentBlock] = [] + for part in parts: + block = part_to_content_block(part) + if block is not None: + blocks.append(block) + return blocks diff --git a/tests/unittests/planners/test_planner_content_blocks.py b/tests/unittests/planners/test_planner_content_blocks.py new file mode 100644 index 00000000000..87ca8cd0770 --- /dev/null +++ b/tests/unittests/planners/test_planner_content_blocks.py @@ -0,0 +1,289 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for standardized planner content blocks.""" + +from google.adk.planners.built_in_planner import BuiltInPlanner +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 PlanReActPlanner +from google.adk.planners.plan_re_act_planner import REASONING_TAG +from google.adk.planners.plan_re_act_planner import REPLANNING_TAG +from google.adk.planners.planner_content_blocks import part_to_content_block +from google.adk.planners.planner_content_blocks import parts_to_content_blocks +from google.genai import types + + +# --------------------------------------------------------------------------- +# part_to_content_block +# --------------------------------------------------------------------------- + + +def test_plain_text_part_becomes_text_block(): + part = types.Part(text='the final answer') + assert part_to_content_block(part) == { + 'type': 'text', + 'text': 'the final answer', + } + + +def test_thought_part_becomes_reasoning_block(): + part = types.Part(text='some thinking', thought=True) + assert part_to_content_block(part) == { + 'type': 'reasoning', + 'reasoning': 'some thinking', + 'reasoning_kind': None, + } + + +def test_function_call_part_becomes_tool_call_block(): + part = types.Part.from_function_call(name='get_weather', args={'city': 'SF'}) + assert part_to_content_block(part) == { + 'type': 'tool_call', + 'name': 'get_weather', + 'args': {'city': 'SF'}, + 'id': None, + } + + +def test_function_call_preserves_id(): + part = types.Part.from_function_call(name='get_weather', args={}) + part.function_call.id = 'call_123' + block = part_to_content_block(part) + assert block['id'] == 'call_123' + assert block['args'] == {} + + +def test_empty_function_call_name_is_skipped(): + # A function call with no name carries no usable content. + part = types.Part.from_function_call(name='', args={'x': 1}) + assert part_to_content_block(part) is None + + +def test_empty_text_part_is_skipped(): + assert part_to_content_block(types.Part(text='')) is None + assert part_to_content_block(types.Part()) is None + + +def test_redacted_thought_without_text_is_skipped(): + # e.g. an Anthropic redacted-thinking part that only carries a signature. + part = types.Part(thought=True, thought_signature=b'sig') + assert part_to_content_block(part) is None + + +def test_function_call_takes_precedence_over_text(): + part = types.Part.from_function_call(name='do_it', args={}) + part.text = 'incidental text' + block = part_to_content_block(part) + assert block['type'] == 'tool_call' + assert block['name'] == 'do_it' + + +# --------------------------------------------------------------------------- +# PlanReActPlanner tag -> reasoning_kind mapping +# --------------------------------------------------------------------------- + + +def test_planning_tag_maps_to_planning_kind(): + part = types.Part(text=f'{PLANNING_TAG}\n1. do a thing', thought=True) + block = part_to_content_block(part) + assert block['type'] == 'reasoning' + assert block['reasoning_kind'] == 'planning' + # The leading tag is stripped from the standardized reasoning text. + assert block['reasoning'] == '1. do a thing' + + +def test_each_reasoning_tag_maps_to_expected_kind(): + cases = { + PLANNING_TAG: 'planning', + REPLANNING_TAG: 'replanning', + REASONING_TAG: 'reasoning', + ACTION_TAG: 'action', + } + for tag, expected_kind in cases.items(): + part = types.Part(text=f'{tag} body', thought=True) + block = part_to_content_block(part) + assert block['reasoning_kind'] == expected_kind, tag + assert block['reasoning'] == 'body', tag + + +def test_thought_without_recognized_tag_has_none_kind(): + part = types.Part(text='free-form thought', thought=True) + block = part_to_content_block(part) + assert block['reasoning_kind'] is None + assert block['reasoning'] == 'free-form thought' + + +def test_trailing_final_answer_tag_stripped_from_reasoning_block(): + # PlanReActPlanner keeps the FINAL_ANSWER separator on the reasoning part; it + # should not leak into the standardized reasoning text. + part = types.Part(text=f'{REASONING_TAG} got it{FINAL_ANSWER_TAG}', thought=True) + block = part_to_content_block(part) + assert block['type'] == 'reasoning' + assert block['reasoning_kind'] == 'reasoning' + assert block['reasoning'] == 'got it' + + +def test_final_answer_tag_stripped_from_text_block(): + part = types.Part(text=f'{FINAL_ANSWER_TAG} here it is') + block = part_to_content_block(part) + assert block['type'] == 'text' + assert block['text'] == 'here it is' + + +# --------------------------------------------------------------------------- +# parts_to_content_blocks +# --------------------------------------------------------------------------- + + +def test_parts_to_content_blocks_empty_and_none(): + assert parts_to_content_blocks(None) == [] + assert parts_to_content_blocks([]) == [] + + +def test_parts_to_content_blocks_skips_unmappable_parts(): + parts = [ + types.Part(text='reasoning', thought=True), + types.Part(), # unmappable -> skipped + types.Part(text='answer'), + ] + blocks = parts_to_content_blocks(parts) + assert [b['type'] for b in blocks] == ['reasoning', 'text'] + + +def test_parts_to_content_blocks_preserves_order(): + parts = [ + types.Part(text=f'{PLANNING_TAG} plan', thought=True), + types.Part.from_function_call(name='search', args={'q': 'x'}), + types.Part(text=f'{REASONING_TAG} summary', thought=True), + types.Part(text='final'), + ] + blocks = parts_to_content_blocks(parts) + assert blocks == [ + {'type': 'reasoning', 'reasoning': 'plan', 'reasoning_kind': 'planning'}, + {'type': 'tool_call', 'name': 'search', 'args': {'q': 'x'}, 'id': None}, + { + 'type': 'reasoning', + 'reasoning': 'summary', + 'reasoning_kind': 'reasoning', + }, + {'type': 'text', 'text': 'final'}, + ] + + +# --------------------------------------------------------------------------- +# Read-only guarantee +# --------------------------------------------------------------------------- + + +def test_conversion_does_not_mutate_parts(): + thought = types.Part(text=f'{PLANNING_TAG} plan', thought=True) + text = types.Part(text='answer') + fc = types.Part.from_function_call(name='f', args={'a': 1}) + before = [p.model_copy(deep=True) for p in (thought, text, fc)] + + parts_to_content_blocks([thought, text, fc]) + + for original, after in zip(before, (thought, text, fc)): + assert original == after + + +def test_returned_args_is_a_copy(): + fc = types.Part.from_function_call(name='f', args={'a': 1}) + block = part_to_content_block(fc) + block['args']['a'] = 999 + # Mutating the returned block must not affect the source part. + assert fc.function_call.args == {'a': 1} + + +# --------------------------------------------------------------------------- +# Planner method integration +# --------------------------------------------------------------------------- + + +def test_plan_re_act_planner_round_trip_plan_then_tool_call(): + """process_planning_response output converts to faithful content blocks. + + When a function call is present, the planner intentionally keeps the leading + plan plus the function-call group and drops trailing prose, so the + standardized view contains exactly those blocks. + """ + planner = PlanReActPlanner() + raw_parts = [ + types.Part(text=f'{PLANNING_TAG}\n1. call the tool'), + types.Part.from_function_call(name='get_weather', args={'city': 'SF'}), + types.Part(text='trailing prose that the planner drops'), + ] + + processed = planner.process_planning_response( + callback_context=None, response_parts=raw_parts + ) + blocks = planner.to_content_blocks(processed) + + assert blocks == [ + { + 'type': 'reasoning', + 'reasoning': '1. call the tool', + 'reasoning_kind': 'planning', + }, + { + 'type': 'tool_call', + 'name': 'get_weather', + 'args': {'city': 'SF'}, + 'id': None, + }, + ] + + +def test_plan_re_act_planner_round_trip_reasoning_then_final_answer(): + """A single reasoning/final-answer part splits into reasoning + text blocks.""" + planner = PlanReActPlanner() + raw_parts = [ + types.Part(text=f'{REASONING_TAG} got it{FINAL_ANSWER_TAG} It is sunny.'), + ] + + processed = planner.process_planning_response( + callback_context=None, response_parts=raw_parts + ) + blocks = planner.to_content_blocks(processed) + + assert blocks == [ + { + 'type': 'reasoning', + 'reasoning': 'got it', + 'reasoning_kind': 'reasoning', + }, + # The text block preserves the planner's verbatim answer text (including + # the leading space the planner left after the FINAL_ANSWER tag); only the + # ADK control tag itself is removed, never user-visible content. + {'type': 'text', 'text': ' It is sunny.'}, + ] + + +def test_built_in_planner_to_content_blocks(): + planner = BuiltInPlanner(thinking_config=types.ThinkingConfig()) + parts = [ + types.Part(text='internal thinking', thought=True), + types.Part(text='visible answer'), + ] + blocks = planner.to_content_blocks(parts) + assert blocks == [ + { + 'type': 'reasoning', + 'reasoning': 'internal thinking', + 'reasoning_kind': None, + }, + {'type': 'text', 'text': 'visible answer'}, + ] From 3dc6e40ab4e04200afbed87a81eb114c6fa860eb Mon Sep 17 00:00:00 2001 From: vaibhav-patel Date: Mon, 22 Jun 2026 15:33:59 +0530 Subject: [PATCH 2/2] chore: re-trigger CI checks