From 157946f3b4ec308a6367b8fa32c59dc4f5970bac Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Fri, 6 Feb 2026 03:53:28 -0500 Subject: [PATCH 1/5] Fix AgentTool skip_summarization to include text output in final event When skip_summarization=True, AgentTool previously terminated the flow with a FunctionResponse event that contained no text, causing UIs to appear stuck. This change appends a Text part to the response event when skipping summarization so tool output is displayed. The implementation avoids duplication, safely serializes non-string results, and preserves existing behavior for normal flows. Added regression tests to verify text output is present for both string and JSON-like tool results. --- src/google/adk/flows/llm_flows/functions.py | 12 ++++ tests/unittests/tools/test_agent_tool.py | 76 +++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 664d0d6c67..f4601148d8 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -21,6 +21,7 @@ import copy import functools import inspect +import json import logging import threading from typing import Any @@ -962,6 +963,17 @@ def __build_response_event( parts=[part_function_response], ) + if tool_context.actions.skip_summarization: + # When summarization is skipped, ensure a displayable text part is added. + if isinstance(function_result.get('result'), str): + result_text = function_result['result'] + else: + # Safely serialize non-string results to JSON for display. + result_text = json.dumps( + function_result, ensure_ascii=False, default=str + ) + content.parts.append(types.Part.from_text(text=result_text)) + function_response_event = Event( invocation_id=invocation_context.invocation_id, author=invocation_context.agent.name, diff --git a/tests/unittests/tools/test_agent_tool.py b/tests/unittests/tools/test_agent_tool.py index b5f59be0fc..e7d3167b66 100644 --- a/tests/unittests/tools/test_agent_tool.py +++ b/tests/unittests/tools/test_agent_tool.py @@ -1164,3 +1164,79 @@ def test_empty_sequential_agent_falls_back_to_request(self): # Should fall back to 'request' parameter assert declaration.parameters.properties['request'].type == 'STRING' + + +def test_agent_tool_skip_summarization_has_text_output(): + """Tests that when skip_summarization is True, the final event contains text content.""" + + tool_agent_model = testing_utils.MockModel.create(responses=["tool_response_text"]) + tool_agent = Agent( + name="tool_agent", + model=tool_agent_model, + ) + + agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) + + root_agent_model = testing_utils.MockModel.create( + responses=[ + function_call_no_schema, + "final_summary_text_that_should_not_be_reached", + ] + ) + + root_agent = Agent( + name="root_agent", + model=root_agent_model, + tools=[agent_tool], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + events = runner.run("start") + + final_events = [e for e in events if e.is_final_response()] + assert final_events + last_event = final_events[-1] + assert last_event.is_final_response() + + assert any(p.function_response for p in last_event.content.parts) + + assert any("tool_response_text" in (p.text or "") for p in last_event.content.parts) + + +def test_agent_tool_skip_summarization_preserves_json_string_output(): + """Tests that structured output string is preserved as text when skipping summarization.""" + + tool_agent_model = testing_utils.MockModel.create(responses=['{"field": "value"}']) + tool_agent = Agent( + name="tool_agent", + model=tool_agent_model, + ) + + agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) + + root_agent_model = testing_utils.MockModel.create( + responses=[function_call_no_schema] + ) + + root_agent = Agent( + name="root_agent", + model=root_agent_model, + tools=[agent_tool], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + events = runner.run("start") + + final_events = [e for e in events if e.is_final_response()] + assert final_events + last_event = final_events[-1] + assert last_event.is_final_response() + + text_parts = [p.text for p in last_event.content.parts if p.text] + assert text_parts + + # Check that the JSON string content is preserved + assert any( + "field" in (p.text or "") and "value" in (p.text or "") + for p in last_event.content.parts + ) From ef17a2ad106121e10e73a50b48b640201baacbee Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Fri, 6 Feb 2026 04:21:43 -0500 Subject: [PATCH 2/5] Feat: improve serialization of non-string tool outputs by extracting the 'result' key for JSON display. - assert the exact string content - new test case for a non-string dictionary - more precise assertion checking for the exact text content --- src/google/adk/flows/llm_flows/functions.py | 2 +- tests/unittests/tools/test_agent_tool.py | 44 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index f4601148d8..e6cf8c56f2 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -970,7 +970,7 @@ def __build_response_event( else: # Safely serialize non-string results to JSON for display. result_text = json.dumps( - function_result, ensure_ascii=False, default=str + function_result.get('result', function_result), ensure_ascii=False, default=str ) content.parts.append(types.Part.from_text(text=result_text)) diff --git a/tests/unittests/tools/test_agent_tool.py b/tests/unittests/tools/test_agent_tool.py index e7d3167b66..d8e4753a71 100644 --- a/tests/unittests/tools/test_agent_tool.py +++ b/tests/unittests/tools/test_agent_tool.py @@ -1200,7 +1200,7 @@ def test_agent_tool_skip_summarization_has_text_output(): assert any(p.function_response for p in last_event.content.parts) - assert any("tool_response_text" in (p.text or "") for p in last_event.content.parts) + assert [p.text for p in last_event.content.parts if p.text] == ["tool_response_text"] def test_agent_tool_skip_summarization_preserves_json_string_output(): @@ -1235,8 +1235,42 @@ def test_agent_tool_skip_summarization_preserves_json_string_output(): text_parts = [p.text for p in last_event.content.parts if p.text] assert text_parts - # Check that the JSON string content is preserved - assert any( - "field" in (p.text or "") and "value" in (p.text or "") - for p in last_event.content.parts + # Check that the JSON string content is preserved exactly + assert text_parts == ['{"field": "value"}'] + + +def test_agent_tool_skip_summarization_handles_non_string_result(): + """Tests that non-string (dict) output is correctly serialized as JSON text.""" + + class CustomOutput(BaseModel): + value: int + + tool_agent_model = testing_utils.MockModel.create(responses=['{"value": 123}']) + tool_agent = Agent( + name="tool_agent", + model=tool_agent_model, + output_schema=CustomOutput + ) + + agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) + + root_agent_model = testing_utils.MockModel.create( + responses=[function_call_no_schema] ) + + root_agent = Agent( + name="root_agent", + model=root_agent_model, + tools=[agent_tool], + ) + + runner = testing_utils.InMemoryRunner(root_agent) + events = runner.run("start") + + final_events = [e for e in events if e.is_final_response()] + assert final_events + last_event = final_events[-1] + + text_parts = [p.text for p in last_event.content.parts if p.text] + + assert text_parts == ['{"value": 123}'] From f7f1a0036376bf0030d6cceeb878d9ade6f9d825 Mon Sep 17 00:00:00 2001 From: Mac Howe <69370250+ItsMacto@users.noreply.github.com> Date: Fri, 6 Feb 2026 04:28:32 -0500 Subject: [PATCH 3/5] Update src/google/adk/flows/llm_flows/functions.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk/flows/llm_flows/functions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index e6cf8c56f2..df16a6cd81 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -965,12 +965,13 @@ def __build_response_event( if tool_context.actions.skip_summarization: # When summarization is skipped, ensure a displayable text part is added. - if isinstance(function_result.get('result'), str): - result_text = function_result['result'] + result_payload = function_result.get('result', function_result) + if isinstance(result_payload, str): + result_text = result_payload else: # Safely serialize non-string results to JSON for display. result_text = json.dumps( - function_result.get('result', function_result), ensure_ascii=False, default=str + result_payload, ensure_ascii=False, default=str ) content.parts.append(types.Part.from_text(text=result_text)) From 88ec6c8c94ba75bf22841a97e110907a73f79e04 Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Fri, 6 Feb 2026 04:35:18 -0500 Subject: [PATCH 4/5] refactor: use pytest fixture to de-duplicate AgentTool skip_summarization test setup. --- tests/unittests/tools/test_agent_tool.py | 98 ++++++++---------------- 1 file changed, 34 insertions(+), 64 deletions(-) diff --git a/tests/unittests/tools/test_agent_tool.py b/tests/unittests/tools/test_agent_tool.py index d8e4753a71..49341c07ce 100644 --- a/tests/unittests/tools/test_agent_tool.py +++ b/tests/unittests/tools/test_agent_tool.py @@ -14,6 +14,7 @@ from typing import Any from typing import Optional +import pytest from google.adk.agents.callback_context import CallbackContext from google.adk.agents.invocation_context import InvocationContext @@ -1166,31 +1167,37 @@ def test_empty_sequential_agent_falls_back_to_request(self): assert declaration.parameters.properties['request'].type == 'STRING' -def test_agent_tool_skip_summarization_has_text_output(): - """Tests that when skip_summarization is True, the final event contains text content.""" +@pytest.fixture +def setup_skip_summarization_runner(): + def _setup_runner(tool_agent_model_responses, tool_agent_output_schema=None): + tool_agent_model = testing_utils.MockModel.create(responses=tool_agent_model_responses) + tool_agent = Agent( + name="tool_agent", + model=tool_agent_model, + output_schema=tool_agent_output_schema + ) - tool_agent_model = testing_utils.MockModel.create(responses=["tool_response_text"]) - tool_agent = Agent( - name="tool_agent", - model=tool_agent_model, - ) + agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) - agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) + root_agent_model = testing_utils.MockModel.create( + responses=[ + function_call_no_schema, + "final_summary_text_that_should_not_be_reached", + ] + ) - root_agent_model = testing_utils.MockModel.create( - responses=[ - function_call_no_schema, - "final_summary_text_that_should_not_be_reached", - ] - ) + root_agent = Agent( + name="root_agent", + model=root_agent_model, + tools=[agent_tool], + ) + return testing_utils.InMemoryRunner(root_agent) + return _setup_runner - root_agent = Agent( - name="root_agent", - model=root_agent_model, - tools=[agent_tool], - ) - runner = testing_utils.InMemoryRunner(root_agent) +def test_agent_tool_skip_summarization_has_text_output(setup_skip_summarization_runner): + """Tests that when skip_summarization is True, the final event contains text content.""" + runner = setup_skip_summarization_runner(tool_agent_model_responses=["tool_response_text"]) events = runner.run("start") final_events = [e for e in events if e.is_final_response()] @@ -1203,28 +1210,9 @@ def test_agent_tool_skip_summarization_has_text_output(): assert [p.text for p in last_event.content.parts if p.text] == ["tool_response_text"] -def test_agent_tool_skip_summarization_preserves_json_string_output(): +def test_agent_tool_skip_summarization_preserves_json_string_output(setup_skip_summarization_runner): """Tests that structured output string is preserved as text when skipping summarization.""" - - tool_agent_model = testing_utils.MockModel.create(responses=['{"field": "value"}']) - tool_agent = Agent( - name="tool_agent", - model=tool_agent_model, - ) - - agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) - - root_agent_model = testing_utils.MockModel.create( - responses=[function_call_no_schema] - ) - - root_agent = Agent( - name="root_agent", - model=root_agent_model, - tools=[agent_tool], - ) - - runner = testing_utils.InMemoryRunner(root_agent) + runner = setup_skip_summarization_runner(tool_agent_model_responses=['{"field": "value"}']) events = runner.run("start") final_events = [e for e in events if e.is_final_response()] @@ -1233,38 +1221,20 @@ def test_agent_tool_skip_summarization_preserves_json_string_output(): assert last_event.is_final_response() text_parts = [p.text for p in last_event.content.parts if p.text] - assert text_parts - + # Check that the JSON string content is preserved exactly assert text_parts == ['{"field": "value"}'] -def test_agent_tool_skip_summarization_handles_non_string_result(): +def test_agent_tool_skip_summarization_handles_non_string_result(setup_skip_summarization_runner): """Tests that non-string (dict) output is correctly serialized as JSON text.""" - class CustomOutput(BaseModel): value: int - tool_agent_model = testing_utils.MockModel.create(responses=['{"value": 123}']) - tool_agent = Agent( - name="tool_agent", - model=tool_agent_model, - output_schema=CustomOutput - ) - - agent_tool = AgentTool(agent=tool_agent, skip_summarization=True) - - root_agent_model = testing_utils.MockModel.create( - responses=[function_call_no_schema] + runner = setup_skip_summarization_runner( + tool_agent_model_responses=['{"value": 123}'], + tool_agent_output_schema=CustomOutput ) - - root_agent = Agent( - name="root_agent", - model=root_agent_model, - tools=[agent_tool], - ) - - runner = testing_utils.InMemoryRunner(root_agent) events = runner.run("start") final_events = [e for e in events if e.is_final_response()] From 8c64faab3de48a9c194c75d6d0a43db9c812aa13 Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Fri, 6 Feb 2026 04:42:21 -0500 Subject: [PATCH 5/5] docs: Clarify unwrapping of function results for display when summarization is skipped. --- src/google/adk/flows/llm_flows/functions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index df16a6cd81..605d6fe8ba 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -963,8 +963,10 @@ def __build_response_event( parts=[part_function_response], ) + # When summarization is skipped, ensure a displayable text part is added. if tool_context.actions.skip_summarization: - # When summarization is skipped, ensure a displayable text part is added. + # If the tool returned a non-dict, it was wrapped in {'result': ...}. + # This unwraps the value for display; otherwise, it uses the original dict. result_payload = function_result.get('result', function_result) if isinstance(result_payload, str): result_text = result_payload