diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 795b706559..f28a6638da 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -1202,16 +1202,26 @@ def __build_response_event( function_response_parts, ) - # When summarization is skipped, ensure a displayable text part is added so - # the tool's output is not lost in UIs that don't render function responses. - # Control-flow tools (e.g. exit_loop) set skip_summarization but return no - # meaningful output; their None result is normalized to {'result': None}, so - # skip those to avoid emitting a noisy "null" text part. + # When summarization is skipped for an AgentTool, ensure a displayable text + # part is added so the sub-agent's output is not lost in UIs that don't render + # function responses (see #3881). This is scoped to AgentTool deliberately: + # other tools (e.g. UI/widget-rendering tools) set skip_summarization + # precisely because their function response is an internal acknowledgement + # that must NOT be surfaced as visible text. Control-flow tools (e.g. + # exit_loop) set skip_summarization but return no meaningful output; their + # None result is normalized to {'result': None}, so skip those to avoid + # emitting a noisy "null" text part. + # + # Local import to avoid a circular import (agents -> flows -> tools -> agents); + # AgentTool pulls in the agents package, so it cannot be imported at module top. + from ...tools.agent_tool import AgentTool + has_displayable_result = display_result is not None and display_result != { 'result': None } if ( - tool_context.actions.skip_summarization + isinstance(tool, AgentTool) + and tool_context.actions.skip_summarization and 'error' not in function_result and has_displayable_result ): diff --git a/tests/unittests/flows/llm_flows/test_functions_simple.py b/tests/unittests/flows/llm_flows/test_functions_simple.py index 2720967d29..a56c2d4aae 100644 --- a/tests/unittests/flows/llm_flows/test_functions_simple.py +++ b/tests/unittests/flows/llm_flows/test_functions_simple.py @@ -1623,6 +1623,47 @@ def simple_fn(**kwargs) -> dict: assert function_response.scheduling is None +@pytest.mark.asyncio +async def test_skip_summarization_non_agent_tool_appends_no_text_part(): + """A non-AgentTool with skip_summarization must not emit a visible text part. + + The skip_summarization text-append in __build_response_event exists to keep + AgentTool output visible in UIs that don't render function responses (#3881). + It must not fire for other tools: UI/widget tools set skip_summarization + because their function response is an internal acknowledgement, not + user-facing text. A FunctionTool result must therefore produce only a + function_response part (no Part.from_text), otherwise the ack payload would be + surfaced to the UI as visible text. + """ + + def render_widget(tool_context: ToolContext) -> dict: + tool_context.actions.skip_summarization = True + return {'status': 'ok', 'widget': 'rendered'} + + tool = FunctionTool(render_widget) + model = testing_utils.MockModel.create(responses=[]) + agent = Agent(name='test_agent', model=model, tools=[tool]) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='' + ) + function_call = types.FunctionCall(name=tool.name, args={}, id='fc_test') + event = Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + content=types.Content(parts=[types.Part(function_call=function_call)]), + ) + + result_event = await handle_function_calls_async( + invocation_context, event, {tool.name: tool} + ) + + assert result_event is not None + assert result_event.actions.skip_summarization is True + # The ack is carried only as a function_response part — never as text. + assert any(p.function_response is not None for p in result_event.content.parts) + assert all(p.text is None for p in result_event.content.parts) + + async def _drain_live_function_responses( live_request_queue: LiveRequestQueue, count: int,