Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/google/adk/agents/remote_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = (
Expand Down
65 changes: 65 additions & 0 deletions tests/unittests/agents/test_remote_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down