From b1e8fa38549f130b7877e420a293a8e43128bcfd Mon Sep 17 00:00:00 2001 From: Jeeho Lee Date: Thu, 8 Oct 2026 11:45:03 +0000 Subject: [PATCH] fix(workflow): exclude thoughts from typed node inputs Use the existing answer-text helper for schema validation and function argument coercion so thoughts do not invalidate JSON or enter string input. Cover both routes through real workflow execution and focused schema tests. Assisted-by: OpenAI Codex --- src/google/adk/utils/_schema_utils.py | 5 +-- src/google/adk/workflow/_function_node.py | 12 +++--- tests/unittests/utils/test_schema_utils.py | 26 +++++++++++++ .../unittests/workflow/test_function_node.py | 37 +++++++++++++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 0ba1f49b4d7..c249978c9e3 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -37,6 +37,7 @@ from typing_extensions import Annotated from . import _json_utils +from .content_utils import extract_text_from_content logger = logging.getLogger("google_adk." + __name__) @@ -312,9 +313,7 @@ def _validate_python_object(val: Any) -> Any: return _validate_python_object(data) if isinstance(data, types.Content): - # Extract text part - text_parts = [p.text for p in data.parts if p.text] if data.parts else [] - text_str = "".join(text_parts) + text_str = extract_text_from_content(data) # Validate the text if schema is str: diff --git a/src/google/adk/workflow/_function_node.py b/src/google/adk/workflow/_function_node.py index 883e7fa91f8..9561cbe0bca 100644 --- a/src/google/adk/workflow/_function_node.py +++ b/src/google/adk/workflow/_function_node.py @@ -44,6 +44,7 @@ from ..utils._schema_utils import annotation_accepts_content from ..utils._schema_utils import annotation_expects_str from ..utils._sync_runner import _SYNC_CALLABLE_RUNNER +from ..utils.content_utils import extract_text_from_content from ..utils.context_utils import Aclosing from ._base_node import BaseNode from ._errors import WorkflowConfigurationError @@ -205,12 +206,11 @@ def _step_in_pool() -> tuple[str, Any]: def _content_to_str( content: types.Content, func_name: str, param_name: str ) -> str: - """Extracts text from a Content object, warning on non-text parts.""" - texts = [] + """Extracts answer text from Content, warning on non-text parts.""" for part in content.parts or []: - if part.text is not None: - texts.append(part.text) - elif part.inline_data or part.file_data or part.executable_code: + if part.text is None and ( + part.inline_data or part.file_data or part.executable_code + ): logger.warning( 'Parameter "%s" of function "%s" expects str but received' " Content with non-text parts (e.g. inline_data, file_data)." @@ -218,7 +218,7 @@ def _content_to_str( param_name, func_name, ) - return "".join(texts) + return extract_text_from_content(content) _expects_str = annotation_expects_str diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index aaeb48cc654..0138e6452a6 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -300,6 +300,32 @@ def test_strip_json_code_fence_variations(self): class TestValidateNodeData: """Tests for validate_node_data function.""" + @pytest.mark.parametrize("preserve_content", [False, True]) + @pytest.mark.parametrize("schema", [str, SampleModel]) + def test_content_thoughts_are_excluded_from_validated_text( + self, schema, preserve_content + ): + """Only answer text is validated or returned as a typed node payload.""" + answer = "hello" if schema is str else '{"name": "test", "value": 42}' + data = types.Content( + role="model", + parts=[ + types.Part(text="Let me think first.", thought=True), + types.Part(text=answer), + ], + ) + + result = validate_node_data(schema, data, preserve_content=preserve_content) + + if preserve_content: + assert result == types.Content( + role="model", parts=[types.Part(text=answer)] + ) + else: + assert result == ( + answer if schema is str else {"name": "test", "value": 42} + ) + def test_none_schema_or_data_returns_data(self): """Bypasses validation if schema or data is None.""" assert validate_node_data(None, "some_data") == "some_data" diff --git a/tests/unittests/workflow/test_function_node.py b/tests/unittests/workflow/test_function_node.py index 34ccdfee986..400ba3e2287 100644 --- a/tests/unittests/workflow/test_function_node.py +++ b/tests/unittests/workflow/test_function_node.py @@ -1418,6 +1418,43 @@ def produce() -> dict: assert received == [_OutputModel(name='test', value=42)] +@pytest.mark.parametrize('structured', [False, True]) +async def test_typed_function_receives_answer_without_thoughts(structured): + """A workflow passes the answer, rather than thoughts, into typed functions.""" + received = [] + + def process_text(node_input: str) -> str: + received.append(node_input) + return 'ok' + + def process_model(node_input: _OutputModel) -> str: + received.append(node_input) + return 'ok' + + answer = '{"name": "test", "value": 42}' if structured else 'hello' + + def produce() -> Event: + return Event( + output=types.Content( + role='model', + parts=[ + types.Part(text='Let me think first.', thought=True), + types.Part(text=answer), + ], + ) + ) + + process = process_model if structured else process_text + agent = Workflow( + name='typed_answer', edges=[(START, produce), (produce, process)] + ) + await run_workflow(agent) + + assert received == ( + [_OutputModel(name='test', value=42)] if structured else ['hello'] + ) + + @pytest.mark.asyncio async def test_input_schema_rejects_invalid_dict( request: pytest.FixtureRequest,