Skip to content

fix(a2a): mark terminal A2A tasks as final responses - #6872

Open
Akshaay1 wants to merge 1 commit into
google:mainfrom
Akshaay1:fix/a2a-terminal-task-final-response
Open

fix(a2a): mark terminal A2A tasks as final responses#6872
Akshaay1 wants to merge 1 commit into
google:mainfrom
Akshaay1:fix/a2a-terminal-task-final-response

Conversation

@Akshaay1

Copy link
Copy Markdown

Fixes #6584.

Problem

convert_a2a_task_to_event never reflected the A2A task's terminal state onto the event it produced.

When a peer does not advertise streaming, the a2a-sdk falls back to message/send and the entire turn arrives as a single task. The converted event therefore carries the peer's narration, its function_calls, its function_responses and its closing text all together.

Event.is_final_response() returns False for any event holding function calls or responses:

return (
    not self.get_function_calls()
    and not self.get_function_responses()
    and not self.partial
    and not self.has_trailing_code_execution_result()
)

So ADK produced a completed task as the last event of the invocation, and its own public helper could not recognise it as the end of the turn. A consumer that closes the turn on is_final_response() — which that helper's docstring explicitly recommends ("Application and UI layers can rely on this helper to detect a complete, user-facing response instead of replicating its logic") — never closed it.

This is the default path for an ADK-served peer, not an exotic one: to_a2a(...) builds its card through AgentCardBuilder with capabilities.streaming left at False.

Fix

Set event.actions.skip_summarization when the task state is completed, failed or canceled.

Reasoning for that choice:

  • skip_summarization is already the established signal for "this event is final despite carrying tool activity" — it is the first thing is_final_response() checks.
  • It is already trusted from a remote peer: it is one of only three entries in this module's _PEER_SETTABLE_ACTION_FIELDS allowlist, whose docstring classifies it as inert, in contrast to the fields that "mutate the caller's own session ... or drive the caller's control flow and persistence". Setting it here therefore does not widen what an A2A response can influence.
  • Blast radius is narrow: across the entire codebase, event.actions.skip_summarization has exactly one behavioural consumer (Event.is_final_response()); the only other reference is persistence in vertex_ai_session_service. The functions.py consumer reads tool_context.actions, which is a different object on the local tool-execution path.
  • Event.is_final_response() itself is left untouched, so the generic contract does not change.

input_required and auth_required are deliberately excluded. They pause the turn to ask the caller for something rather than ending it, and they already reach is_final_response() through the mock function call _create_mock_function_call_for_required_user_input builds for them. This was the edge case raised in review on the issue, and it is covered by a test that asserts those states are left exactly as they were.

Applied to both converters — the v2 to_adk_event.py and the legacy event_converter.py that RemoteA2aAgent imports — since the issue reports both paths behave the same way.

In the legacy converter the state is read through a small _is_terminal_task() helper using getattr, because 1.x tasks carry a protobuf TaskStatus whose fields are not always reachable on stand-in objects; an unreadable state simply means we cannot claim the turn is over.

Before / after

Reproduced deterministically — no API key and no model call. A completed task carrying one function call and its response:

task state is_final_response() before after
completed False True
failed False True
canceled False True
working False False ✅ (unchanged)
input_required True True ✅ (unchanged)

Tests

Added to both converter test modules:

  • test_convert_a2a_task_to_event_terminal_task_is_final_response — parametrized over completed / failed / canceled; asserts the tool activity is still carried on the event and that the turn now reads as closed.
  • test_convert_a2a_task_to_event_non_terminal_task_is_not_final / ..._working_task_is_not_final — an in-flight task must keep reporting the turn is open.
  • test_convert_a2a_task_to_event_pause_state_left_untouchedinput_required / auth_required keep resolving through the mock function call, with skip_summarization untouched.

Verified the new tests fail without the source change (git stash on the two source files only): the 6 terminal-state cases fail, and the guard tests pass either way, as regression guards should.

tests/unittests/a2a/converters/   223 passed, 6 skipped
tests/unittests/a2a/ + events/ + agents/test_remote_a2a_agent.py   804 passed, 45 skipped

Full unit suite: 9095 passed, 56 skipped, 19 xfailed.

The only failures are the two pre-existing test_import_loading.py::test_entry_point_loads_only_allowlisted_packages[agent|runner] cases, which reproduce identically on a clean checkout of main with no changes applied (environment-related, unrelated to this change).

pyink and the pre-commit hooks are clean.

`convert_a2a_task_to_event` never reflected the task's terminal state onto
the event it produced. When a peer does not advertise streaming, the a2a-sdk
falls back to `message/send` and the whole turn arrives as a single task, so
the converted event carries the peer's narration, its function calls, its
function responses and its closing text together.

`Event.is_final_response()` returns False for any event holding function
calls or responses, so that event — the last one of the invocation — was
never recognised as the end of the turn. A consumer that closes the turn on
`is_final_response()`, which the helper's own docstring recommends, never
closed it.

This is the default path for an ADK-served peer: `to_a2a()` builds its card
through `AgentCardBuilder` with `capabilities.streaming` left at False.

Set `skip_summarization` when the task state is completed, failed or
canceled. It is the existing signal for "final despite carrying tool
activity", and it is already trusted from a peer via
`_PEER_SETTABLE_ACTION_FIELDS`, so this does not widen what an A2A response
can influence. `Event.is_final_response()` is left untouched.

`input_required` and `auth_required` are deliberately excluded: they pause
the turn rather than end it, and already reach `is_final_response()` through
the mock function call built for them.

Applied to both the v2 converter and the legacy one that `RemoteA2aAgent`
imports.

Fixes google#6584
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.

RemoteA2aAgent: a terminal A2A task carrying tool activity fails is_final_response(), so the turn cannot be detected as finished (non-streaming peer)

2 participants