Skip to content

fix(a2a): render a historical function response as text - #6844

Open
Akshaay1 wants to merge 1 commit into
google:mainfrom
Akshaay1:fix/a2a-historical-function-response-as-text
Open

fix(a2a): render a historical function response as text#6844
Akshaay1 wants to merge 1 commit into
google:mainfrom
Akshaay1:fix/a2a-historical-function-response-as-text

Conversation

@Akshaay1

Copy link
Copy Markdown

Link to Issue or Description of Change

Problem:

Under mode='task' delegation, succeeding at one delegation breaks every later delegation to a remote peer in the same turn.

When a task delegation completes, ADK synthesizes its function response as an event authored "user" (workflow/_llm_agent_wrapper.py::_synthesize_task_fr_event). RemoteA2aAgent._construct_message_parts_from_session then rebuilds the next peer's request from raw session history.

That rebuild already renders another agent's events as text via _present_other_agent_message — including their function responses. But the synthesized function response is authored "user", so _is_other_agent_reply is False and it is never routed through that path. It was re-serialized verbatim as a DataPart, next to the text parts of the same history.

The receiving agent's runner rejects exactly that combination (runners.py::_validate_new_message):

Message cannot contain both function responses and text. Function responses resume an existing invocation while text starts a new one.

So the first hop looks perfect and everything after it fails. The A2A task comes back TASK_STATE_FAILED with that sentence as its status message, and the coordinator's model treats the error string as the peer's answer — from the user's side the second specialist "just didn't do anything".

Note the asymmetry this produced: the coordinator's function call was rendered as text, and the function response to it was not.

Solution:

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. Any function response older than that is history by definition — the peer starting this turn has no invocation to resume — so it is rendered as text rather than sent as data.

The conversion reuses the text rendering already used for this purpose in task mode, so both function calls and function responses now cross the wire as text.

Deliberately scoped:

  • Task mode is untouched. Its existing remote_fc_ids rules still decide conversion there, so a peer's own outstanding calls keep working.
  • The final-event case is untouched. test_construct_message_parts_from_session_foreign_function_response_not_converted pins that behaviour on purpose, and it still passes. Narrowing to "not the final event" fixes the reported failure without reopening a decision that was already made.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added test_construct_message_parts_from_session_historical_function_response_converted to tests/unittests/agents/test_remote_a2a_agent.py, building the session shape ADK produces after one completed task delegation followed by the next delegation.

Confirmed the test fails for the right reason without the source change — the function response reaches the part converter at all:

>     self.mock_genai_part_converter.assert_called_once_with(text_part)
E     AssertionError: Expected 'mock' to be called once. Called 2 times.

1 failed, 229 deselected

With the fix applied:

$ pytest tests/unittests/agents/test_remote_a2a_agent.py -k historical_function_response -q
1 passed, 229 deselected

$ pytest tests/unittests/agents/test_remote_a2a_agent.py -q
230 passed

$ pytest tests/unittests/a2a tests/unittests/agents tests/unittests/flows -q
1987 passed, 45 skipped, 3 xfailed

$ pytest tests/unittests -q -n 4
2 failed, 12554 passed, 92 skipped, 33 xfailed, 1 xpassed, 24 subtests passed

The 2 failures are test_import_loading.py::test_entry_point_loads_only_allowlisted_packages[agent|runner], and they are pre-existing and unrelated — they reproduce identically on an unmodified checkout of main in the same environment:

E  AssertionError: 'from google.adk.runners import Runner' now loads httpx2,
   sitecustomize, which every ADK process would pay for at startup.

That is dependency drift (httpx2 arrives via the current anthropic release; sitecustomize is a venv artifact), not something this change touches — it adds no imports.

pyink --check reports both files unchanged.

Manual End-to-End (E2E) Tests:

Reproduced with the script from the issue, extended to the shape of the live trace reported there — the follow-on FC:math event is what makes the synthesized function response non-final, which is the real-world path:

session_events = [
    event("user", Part(text="biggest trade, then convert it"), role="user"),
    event("orchestrator", Part(function_call=FunctionCall(id="c1", name="trades", args={"q": "..."}))),
    event("user", Part(function_response=FunctionResponse(
        id="c1", name="trades", response={"output": "BTCZ0, value 19050"})), role="user"),
    event("orchestrator", Part(function_call=FunctionCall(id="c2", name="math", args={"x": 19050}))),
]
peer = RemoteA2aAgent(name="math", agent_card="http://127.0.0.1:8091/card.json")
parts, _ = peer._construct_message_parts_from_session(ctx)

Before:

outbound message parts:
  text: biggest trade, then convert it
  text: For context: below is a transcript of what another agent did ...
  text: [orchestrator] called tool `trades` with parameters: ...
  data: struct_value {   fields {     key: "response"     value { ...
  text: For context: below is a transcript of what another agent did ...
  text: [orchestrator] called tool `math` with parameters: ...

carries a function response AND text: True

After:

outbound message parts:
  text: biggest trade, then convert it
  text: For context: below is a transcript of what another agent did ...
  text: [orchestrator] called tool `trades` with parameters: ...
  text: Tool trades returned: {"output": "BTCZ0, value 19050"}
  text: For context: below is a transcript of what another agent did ...
  text: [orchestrator] called tool `math` with parameters: ...

carries a function response AND text: False

The delegation's result still reaches the peer, now carried as text, and the message no longer mixes a function response with text.

Environment: google-adk 2.7.1 (main @ 775c1bd), a2a-sdk 1.1.2, Python 3.12.13, macOS.

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 google#6831
@google-cla

google-cla Bot commented Aug 21, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@Akshaay1

Copy link
Copy Markdown
Author

@googlebot I signed it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A2A: a completed task delegation leaves a user-authored function response that poisons every later delegation in the turn

2 participants