From 1767f6aefff30d5a6cde7562508f9d6cbb6b32d9 Mon Sep 17 00:00:00 2001 From: Akshaay Date: Fri, 21 Aug 2026 16:11:48 +0530 Subject: [PATCH] fix(a2a): render a historical function response as text A completed task delegation is recorded as a function response authored "user" (`_synthesize_task_fr_event`). Because the author is "user", `_is_other_agent_reply` is False, so `_present_other_agent_message` -- which already renders another agent's function responses as text -- never sees it. `_construct_message_parts_from_session` then re-serialized it as a DataPart next to the text parts of the same history, and the receiving runner rejects that combination: Message cannot contain both function responses and text. So the first delegation in a turn succeeded and every later delegation to a remote peer in the same turn failed. Only the final session event can be a resume payload, and that path is handled by `_create_a2a_request_for_user_function_response` before the history rebuild ever runs. A function response older than that is history and the peer has no invocation to resume, so render it as text instead of sending it as data. Task mode keeps its existing remote_fc_ids rules unchanged. Fixes #6831 --- src/google/adk/agents/remote_a2a_agent.py | 29 +++++++-- .../unittests/agents/test_remote_a2a_agent.py | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/google/adk/agents/remote_a2a_agent.py b/src/google/adk/agents/remote_a2a_agent.py index 52eb5b59c0..5c8565ef73 100644 --- a/src/google/adk/agents/remote_a2a_agent.py +++ b/src/google/adk/agents/remote_a2a_agent.py @@ -1184,7 +1184,13 @@ def _construct_message_parts_from_session( if fc.id is not None: remote_fc_ids.add(fc.id) + # Only the final session event can be a resume payload, and that path is + # handled by `_create_a2a_request_for_user_function_response` before we ever + # rebuild from history. Anything older is history by definition. + last_session_event = ctx.session.events[-1] if ctx.session.events else None + for event in reversed(events_to_process): + is_last_session_event = event is last_session_event # Drop credential material before anything else looks at the event. # `_present_other_agent_message` renders a function_call as text with its # arguments inlined, so scrubbing after it would be too late. @@ -1224,12 +1230,25 @@ def _construct_message_parts_from_session( # Skip sibling function calls from the coordinator intended for other tools/agents. continue - if ( - self.mode == "task" - and part.function_response - and isinstance(part.function_response, genai_types.FunctionResponse) - and part.function_response.id not in remote_fc_ids + render_function_response_as_text = False + if part.function_response is not None and isinstance( + part.function_response, genai_types.FunctionResponse ): + if self.mode == "task": + render_function_response_as_text = ( + part.function_response.id not in remote_fc_ids + ) + else: + # A function response sitting in history is not a resume payload: + # the peer starting this turn has no invocation to resume. Sending + # it as a DataPart makes the outbound message carry a function + # response next to the text parts of the same history, which the + # receiving runner rejects outright ("Message cannot contain both + # function responses and text"). Render it the way another agent's + # function response is already rendered, as text. + render_function_response_as_text = not is_last_session_event + + if render_function_response_as_text: # Convert non-agent function response to text to prevent A2A server # validation errors. text_content = ( diff --git a/tests/unittests/agents/test_remote_a2a_agent.py b/tests/unittests/agents/test_remote_a2a_agent.py index 975b4ae058..140f282738 100644 --- a/tests/unittests/agents/test_remote_a2a_agent.py +++ b/tests/unittests/agents/test_remote_a2a_agent.py @@ -1338,6 +1338,71 @@ def test_construct_message_parts_from_session_foreign_function_response_not_conv assert len(parts) == 1 assert parts[0] == mock_a2a_part + def test_construct_message_parts_from_session_historical_function_response_converted( + self, + ): + """Test that a function response in history is rendered as text. + + A completed task delegation is recorded as a function response authored + "user" (``_synthesize_task_fr_event``). Because its author is "user", + ``_present_other_agent_message`` does not catch it, so it used to be + re-serialized as a DataPart next to the text parts of the same history. + The receiving runner rejects exactly that combination ("Message cannot + contain both function responses and text"), which broke every delegation + after the first one in the same turn. + + Only the final session event can be a resume payload, and that path is + handled by ``_create_a2a_request_for_user_function_response``; anything + older is history and belongs on the wire as text. + """ + fr_part = Mock() + fr_part.function_call = None + fr_part.text = None + fr_part.function_response = genai_types.FunctionResponse( + id="c1", name="trades", response={"output": "BTCZ0, value 19050"} + ) + fr_event = Mock() + fr_event.author = "user" + fr_event.custom_metadata = None + fr_event.content = Mock() + fr_event.content.parts = [fr_part] + fr_event.get_function_calls.return_value = [] + fr_event.get_function_responses.return_value = [] + + # The coordinator's next delegation lands after the synthesized function + # response, so the function response is no longer the final event and + # cannot be a resume payload. + text_part = Mock() + text_part.function_call = None + text_part.function_response = None + text_part.text = "now convert it" + trailing_event = Mock() + trailing_event.author = "user" + trailing_event.custom_metadata = None + trailing_event.content = Mock() + trailing_event.content.parts = [text_part] + trailing_event.get_function_calls.return_value = [] + trailing_event.get_function_responses.return_value = [] + + self.mock_session.events = [fr_event, trailing_event] + self.mock_genai_part_converter.side_effect = lambda part: ( + _compat.make_text_part(part.text) + ) + + parts, _ = self.agent._construct_message_parts_from_session( + self.mock_context + ) + + # The historical function response never reaches the part converter -- it + # is rendered as text instead, so nothing goes out as a DataPart. + self.mock_genai_part_converter.assert_called_once_with(text_part) + assert len(parts) == 2 + assert ( + _compat.part_text(parts[0]) + == 'Tool trades returned: {"output": "BTCZ0, value 19050"}' + ) + assert _compat.part_text(parts[1]) == "now convert it" + def test_construct_message_parts_from_session_stops_on_agent_reply_when_disabled( self, ):