🔴 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:
pip install "google-adk[a2a]==2.7.1" "a2a-sdk[http-server]==1.1.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.
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?:
🔴 Required Information
Describe the Bug:
Under
mode='task'delegation, succeeding at one delegation breaks every laterdelegation 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):RemoteA2aAgent._construct_message_parts_from_sessionthen rebuilds the nextpeer's request from raw session history. It renders another agent's events
as text via
_present_other_agent_message, but that synthesized FR is authoreduser, so_is_other_agent_replyis False and thefunction_responseisre-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):So the first hop looks perfect and everything after it fails.
Steps to Reproduce:
pip install "google-adk[a2a]==2.7.1" "a2a-sdk[http-server]==1.1.2"check_history.py. Itbuilds the session shape ADK produces after one completed task
delegation, and asks
RemoteA2aAgentwhat it would send to the nextpeer.
python check_history.pyExpected 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 noinvocation 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:
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:The A2A task comes back
TASK_STATE_FAILEDwith that sentence as its statusmessage. 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:
Model Information:
message construction and does not depend on the model.
🟡 Optional Information
Regression:
Reachable once a
mode='task'sub-agent is used, because that is whatsynthesizes a
user-authored function response. It is not specific to remotetask peers, though: any
user-authoredfunction_responsesitting in sessionhistory 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_sessionalready has a branch thatturns another agent's events into text, and a
user-authored function responsein history (as opposed to the last event, which is handled by
_create_a2a_request_for_user_function_response) belongs in that branch ratherthan on the wire as data.
We are unblocked by overriding
_construct_message_parts_from_sessionin aRemoteA2aAgentsubclass: post-filter the returned parts, rendering any partwhose metadata says
adk_typeisfunction_call/function_responseas atext part instead, and dropping
adk_request_confirmation/adk_request_inputbookkeeping outright (a conversation with the human, notwith a peer). With that in place the same turn runs
orchestrator -> trades(human-approved BigQuery read) and thenorchestrator -> math -> currency, 5/5 consecutive runs.Minimal Reproduction Code:
How often has this issue occurred?: