Skip to content

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

Description

@msteiner-google

🔴 Required Information

Describe the Bug:

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 by "user"
(workflow/_llm_agent_wrapper.py:257-278):

def _synthesize_task_fr_event(fc: types.FunctionCall, output: Any) -> Event:
  ...
  return Event(
      author='user',
      content=types.Content(role='user', parts=[fr_part]),
  )

RemoteA2aAgent._construct_message_parts_from_session then rebuilds the next
peer's request from raw session history. It renders another agent's events
as text via _present_other_agent_message, but that synthesized FR is authored
user, so _is_other_agent_reply is False and the function_response is
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.

Steps to Reproduce:

  1. pip install "google-adk[a2a]==2.7.1" "a2a-sdk[http-server]==1.1.2"
  2. Save the script under "Minimal Reproduction Code" as check_history.py. It
    builds the session shape ADK produces after one completed task
    delegation
    , and asks RemoteA2aAgent what it would send to the next
    peer.
  3. python check_history.py

Expected Behavior:

The message sent to a peer that is starting a fresh turn contains no
function_response — the completed delegation is history, and the peer has no
invocation to resume. Rendering it as text (as ADK already does for another
agent's tool call one line above) would carry the same information safely.

Observed Behavior:

outbound message parts:
  text: biggest trade, then convert it
  text: For context:
  text: [orchestrator] called tool `trades` with parameters: {'q': '...'}
  data: struct_value {   fields {     key: "response" ...

carries a function response AND text: True

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

Live, against three agents chained over A2A (orchestrator -> trades,
orchestrator -> math -> currency), one user turn:

[trades] FR:run_trade_query={"status": "executed", "rows": [...]}   <- first hop OK
[user]   FR:trades={"output": "..."}                                <- synthesized
[orchestrator] FC:math
[math]   text:Message cannot contain both function responses and text. ...
[user]   FR:math={"output": "Message cannot contain both function responses and text. ..."}
[orchestrator] FC:math                                              <- model retries
[math]   text:Message cannot contain both function responses and text. ...

The A2A task comes back TASK_STATE_FAILED with that sentence as its status
message. The coordinator's model then treats the error string as the peer's
answer and either retries or reports around it, so from the user's side the
second specialist "just didn't do anything".

Environment Details:

  • ADK Library Version: 2.7.1
  • Desktop OS: macOS (Apple Silicon)
  • Python Version: 3.14.6
  • a2a-sdk: 1.1.2

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: gemini-2.5-flash / gemini-2.5-pro. The bug is in
    message construction and does not depend on the model.

🟡 Optional Information

Regression:

Reachable once a mode='task' sub-agent is used, because that is what
synthesizes a user-authored function response. It is not specific to remote
task peers, though: any user-authored function_response sitting in session
history is re-serialized the same way.

Additional Context:

Related in kind to #6721 — both are cases where the A2A request builder decides
what a part means from something other than its origin. The fix shape looks
similar too: _construct_message_parts_from_session already has a branch that
turns another agent's events into text, and a user-authored function response
in history (as opposed to the last event, which is handled by
_create_a2a_request_for_user_function_response) belongs in that branch rather
than on the wire as data.

We are unblocked by overriding _construct_message_parts_from_session in a
RemoteA2aAgent subclass: post-filter the returned parts, rendering any part
whose metadata says adk_type is function_call / function_response as a
text part instead, and dropping adk_request_confirmation /
adk_request_input bookkeeping outright (a conversation with the human, not
with a peer). With that in place the same turn runs
orchestrator -> trades (human-approved BigQuery read) and then
orchestrator -> math -> currency, 5/5 consecutive runs.

Minimal Reproduction Code:

"""ADK 2.7.1: a completed task delegation poisons every later delegation in the turn."""

from types import SimpleNamespace

from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.events.event import Event
from google.genai import types


def event(author, *parts, **kw):
    return Event(
        author=author,
        invocation_id="inv-1",
        content=types.Content(role=kw.pop("role", "model"), parts=list(parts)),
        **kw,
    )


# The turn so far: the user asked, the coordinator delegated to the task-mode
# peer `trades`, and that delegation COMPLETED. The last event is the function
# response ADK synthesizes for it -- authored by "user"
# (workflow/_llm_agent_wrapper.py:_synthesize_task_fr_event).
session_events = [
    event("user", types.Part(text="biggest trade, then convert it"), role="user"),
    event(
        "orchestrator",
        types.Part(
            function_call=types.FunctionCall(id="c1", name="trades", args={"q": "..."})
        ),
    ),
    event(
        "user",
        types.Part(
            function_response=types.FunctionResponse(
                id="c1", name="trades", response={"output": "BTCZ0, value 19050"}
            )
        ),
        role="user",
    ),
]

ctx = SimpleNamespace(
    session=SimpleNamespace(
        id="s1", app_name="app", user_id="u", events=session_events
    ),
    app_name="app",
    user_id="u",
    invocation_id="inv-1",
    branch=None,
)

# Now the coordinator delegates to a DIFFERENT peer. `math` was never paused and
# has no function call outstanding, so this goes through the history rebuild.
peer = RemoteA2aAgent(name="math", agent_card="http://127.0.0.1:8091/card.json")
parts, _ = peer._construct_message_parts_from_session(ctx)

print("outbound message parts:")
for p in parts:
    kind = p.WhichOneof("content")
    body = p.text if kind == "text" else str(p.data).replace("\n", " ")
    print(f"  {kind}: {body[:90]}")

kinds = {p.WhichOneof("content") for p in parts}
print()
print("carries a function response AND text:", kinds == {"text", "data"})

How often has this issue occurred?:

  • Always (100%)

Metadata

Metadata

Assignees

No one assigned

    Labels

    a2a[Component] This issue is related a2a support inside ADK.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions