Repository navigation
Conversation
When a remote A2A task ends in TASK_STATE_FAILED, RemoteA2aAgent yielded the failure as an ordinary answer: the remote's text as content, and no error_code or error_message. The response converters read the task's message but not its state, and only task mode handled the failure. The failure is now marked where _run_async_impl dispatches each response, so it covers both handlers (a non-ADK server never sets the extension marker that routes to _handle_a2a_response_v2), both shapes (a TaskStatusUpdateEvent when streamed, the Task when not) and any custom a2a_*_converter. The event gets error_code A2A_TASK_FAILED and the remote's text as error_message, or one is created when the response converted to none. Its content is kept. Task mode keeps its own failure events, and other terminal states are not marked. Redoes google#6713 on the current layout: RemoteA2aAgent moved to a2a/agent/_remote_a2a_agent.py in a6a4052. Closes google#6708
|
thanks @ferponse, ran 23d253f with an A2A 1.2.1 server that writes before FAILED, streamed and nonstreamed. The write stays visible and ADK reports A2A_TASK_FAILED. One wrinkle: bare nonstreamed FAILED reused earlier "working" text as error_message; streaming used the generic fallback. runnable cases, with a separate installed reader. Happy to lift the after-write cases into your tests. |
A non-streamed failure arrives as the task itself. With no status message, the task converter falls back to the task's history, whose last agent message is what the remote agent said while working, and that text became the error message. A streamed failure with no reason already got the generic message. Take the error message only from the failed status's own message, so both shapes agree.
|
Thanks @astrogilda, good catch. Reproduced it: with no status message, a non-streamed failure arrives as the task itself, and the task converter falls back to the last agent message in the history, so the working text became the On lifting your after-write cases into the tests: the new test covers the same shape (progress before a bare FAILED) with the file's own harness, so I'd rather keep the suite self-contained, but thanks for the offer and for the runnable cases. |
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
Problem:
When a remote A2A task ends in
TASK_STATE_FAILED,RemoteA2aAgentyields the failure as an ordinary answer. The event has the remote agent's text ascontent, and botherror_codeanderror_messageareNone. A caller cannot tell "the remote agent answered" from "the remote agent's task failed", and the failure's text goes into the conversation history as if it were a reply.The response converters (
convert_a2a_task_to_event,convert_a2a_status_update_to_event, and the legacy ones) read the task's message but not its state.taskmode already handles the failure separately, with_create_task_failure_events. The default mode does nothing.Two details mean the fix cannot live in just one place:
_handle_a2a_response_v2only runs when the task metadata carries ADK's integration-extension marker. A non-ADK A2A server never sets it, so its responses always go through_handle_a2a_response.TaskStatusUpdateEventwith the new status. When not streamed, it is theTaskitself.This is the follow-up #6708 asks for. #6713 implemented it, but it no longer applies: since a6a4052
RemoteA2aAgentlives insrc/google/adk/a2a/agent/_remote_a2a_agent.py, andagents/remote_a2a_agent.py, the file that PR edits, only re-exports it. This PR redoes the fix on the current layout. The approach of setting a stableerror_codewith the remote's text aserror_messagecomes from #6713.Solution:
The fix goes where
_run_async_impldispatches each response, right after either handler returns. That one place covers both handlers, both response shapes, and any customa2a_*_converterset inA2aRemoteAgentConfig:_failed_task_status(a2a_response)returns the status of a response that reportsTASK_STATE_FAILED:TaskStatusUpdateEventwhen the response is a status update;Taskwhen it is the task itself;Nonefor a message, an artifact update, or any other state._mark_task_failed(event, status, ctx, agent_name)then:error_code = A2A_TASK_FAILED_ERROR_CODE("A2A_TASK_FAILED"), keeping one the converter already set;error_messageto the status message's text, falling back to the event's own text and then to"Remote A2A task failed";FAILEDstatus update with no message.after_requestinterceptors run, so they see the final event.What does not change:
contentis kept, so anything that reads the remote's text today still finds it. It is now also theerror_message.taskmode is left as it is. It already yields its own error andfinish_taskevents for a failed task, and the check is skipped there.COMPLETED,CANCELED,REJECTED,INPUT_REQUIRED, …) are not marked. OnlyFAILEDmeans the remote task failed; a test pins this._compat.TS_FAILEDis used for the comparison, and_compat.part_text/is_text_partto read the message, so the same code works with a2a-sdk 0.3.x and 1.x.With
error_codeset, the failure event is a final response (Event.is_final_response()) and is stored with the session as an error.Testing Plan
Unit Tests:
New tests in
tests/unittests/a2a/agent/test_remote_a2a_agent.py. They go through the publicRunnerwith the file's existing_run_remote_task_responsesharness, so the real response handlers and converters run:test_failed_remote_task_is_an_error_event(8 cases):error_code,error_message,is_final_response(), the task id metadata, and that the stored session event is the error;test_only_a_failed_remote_task_is_an_error_event(8 cases):COMPLETED,CANCELED,REJECTEDandINPUT_REQUIRED, through both handlers, yield no error.Without the change, the 8 failure cases fail (
assert None == 'A2A_TASK_FAILED') and the 8 other-state cases pass.None of the failures is in a file this PR touches:
code_executors/test_gke_code_executor.py(5);features/(11);tools/spanner/test_spanner_tool_settings.py(1);tools/test_skill_toolset.py::test_integration_python_fifo_in_working_dir_does_not_block(1);integrations/daytonaImportErroronCreateSandboxFromImageParams.Run on their own, those files give the same result with and without this change: the 5 GKE executor tests fail and the rest pass. The other 12 only fail under
-n 8.pre-commit run --files <changed files>passes.mypyon_remote_a2a_agent.pyreports the same single error before and after the change.Manual End-to-End (E2E) Tests:
The script below serves a plain a2a-sdk agent, with no ADK on the server side, whose task narrates while working and then fails. It consumes the agent with
RemoteA2aAgentthrough aRunner, once streamed and once not. It needs no network or credentials.Before (
mainat 63aed55, also released 2.11.0):After:
Reproduction script
Checklist
Additional context
How we work around this today. In production, our orchestrator delegates to a coding agent running in a sandbox, through
RemoteA2aAgent. When that agent's task fails (for exampleclaude exited with code 1), the user saw the error text as an ordinary assistant message. We subclassRemoteA2aAgentand override both handlers, which is the workaround suggested in #4309:It works, but every consumer has to find out on its own that both handlers need overriding: overriding only
_v2silently does nothing against a non-ADK server. It also depends on two private methods. With this change, callers can readevent.error_codeinstead.