Environment
google-adk 2.6.0
a2a-sdk 1.1.2
- Python 3.12
Summary
If the A2A response stream stops before the task reaches a terminal state — the peer's process
dies, its side times out, a proxy closes the connection cleanly — RemoteA2aAgent._run_async_impl
just returns. The async for over the client's responses ends, the generator finishes, and:
- no
Event with an error_message is emitted (the except blocks only fire on an exception, and
a cleanly-closed stream is not one),
- the final task state is never checked,
- neither a state query nor a resubscription is attempted, although the a2a-sdk offers both
(a2a/client/base_client.py: async def get_task(...), and async def subscribe(...), whose
docstring describes its request as "parameters to identify the task to resubscribe to").
The consumer therefore receives a truncated stream that is byte-for-byte indistinguishable from
a complete one: the last events are ordinary non-terminal updates, and the generator ends
normally.
Reproduction
- Serve any ADK agent over A2A with
to_a2a, with a card that advertises streaming
(AgentCardBuilder(..., capabilities=AgentCapabilities(streaming=True))).
- From a
RemoteA2aAgent, start a turn that makes the peer emit at least one working status
update carrying text (any agent that narrates before/while calling a tool does this).
- Kill the peer process while the turn is in flight, after that update has been delivered.
Observed
The client yields the events built from the updates already received and then the invocation ends
silently. Measured on our side, the sequence was:
13:12:52 delegating to the remote agent
13:12:57 function_call / function_response delivered
13:12:57 text delivered on a `working` update
<peer killed>
run_async ends — no error event, no terminal task state, nothing
The peer's task was left in TASK_STATE_WORKING forever, and nothing said so.
Expected
When the stream is exhausted without the task having reached a terminal state, RemoteA2aAgent
should do one of:
- call
client.get_task(...) to resolve the actual final state and emit the corresponding event, or
- resubscribe once through
client.subscribe(...) and continue, or
- failing both, emit an
Event(error_message=...) describing the truncation — the same shape the
except branches already produce.
Any of the three lets a consumer tell "the peer is done" from "the peer vanished".
Worth noting: ADK already models the notion of a terminal A2A event elsewhere — in the workflow
subclass, response promotion is documented as "gated to the first terminal A2A event of the run" —
so the concept exists; it is the normal invocation path that never checks whether one arrived.
Impact / workaround
Any UI that unblocks on a closing event hangs, and there is nothing in the logs to point at: no
exception, no error event. We had to add a guard at the transport boundary — "if the stream ended
and the client was never released, release it and report it" — which is a net that cannot name
the cause, only its absence.
Related: #6274 (a2a-sdk 1.x). Filed alongside a separate issue about is_final_response() not
recognising a terminal task event that carries tool activity, which is the other way a turn can
end without a detectable close.
Environment
google-adk2.6.0a2a-sdk1.1.2Summary
If the A2A response stream stops before the task reaches a terminal state — the peer's process
dies, its side times out, a proxy closes the connection cleanly —
RemoteA2aAgent._run_async_impljust returns. The
async forover the client's responses ends, the generator finishes, and:Eventwith anerror_messageis emitted (theexceptblocks only fire on an exception, anda cleanly-closed stream is not one),
(
a2a/client/base_client.py:async def get_task(...), andasync def subscribe(...), whosedocstring describes its request as "parameters to identify the task to resubscribe to").
The consumer therefore receives a truncated stream that is byte-for-byte indistinguishable from
a complete one: the last events are ordinary non-terminal updates, and the generator ends
normally.
Reproduction
to_a2a, with a card that advertises streaming(
AgentCardBuilder(..., capabilities=AgentCapabilities(streaming=True))).RemoteA2aAgent, start a turn that makes the peer emit at least oneworkingstatusupdate carrying text (any agent that narrates before/while calling a tool does this).
Observed
The client yields the events built from the updates already received and then the invocation ends
silently. Measured on our side, the sequence was:
The peer's task was left in
TASK_STATE_WORKINGforever, and nothing said so.Expected
When the stream is exhausted without the task having reached a terminal state,
RemoteA2aAgentshould do one of:
client.get_task(...)to resolve the actual final state and emit the corresponding event, orclient.subscribe(...)and continue, orEvent(error_message=...)describing the truncation — the same shape theexceptbranches already produce.Any of the three lets a consumer tell "the peer is done" from "the peer vanished".
Worth noting: ADK already models the notion of a terminal A2A event elsewhere — in the workflow
subclass, response promotion is documented as "gated to the first terminal A2A event of the run" —
so the concept exists; it is the normal invocation path that never checks whether one arrived.
Impact / workaround
Any UI that unblocks on a closing event hangs, and there is nothing in the logs to point at: no
exception, no error event. We had to add a guard at the transport boundary — "if the stream ended
and the client was never released, release it and report it" — which is a net that cannot name
the cause, only its absence.
Related: #6274 (a2a-sdk 1.x). Filed alongside a separate issue about
is_final_response()notrecognising a terminal task event that carries tool activity, which is the other way a turn can
end without a detectable close.