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, ):