From 78d6c614a9557ad65d15094aafa79235261539c9 Mon Sep 17 00:00:00 2001 From: Akshaay Date: Mon, 24 Aug 2026 00:55:45 +0530 Subject: [PATCH] fix(a2a): mark terminal A2A tasks as final responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #6584 --- .../adk/a2a/converters/event_converter.py | 24 ++++ src/google/adk/a2a/converters/to_adk_event.py | 24 ++++ .../a2a/converters/test_event_converter.py | 79 ++++++++++++ tests/unittests/a2a/converters/test_to_adk.py | 112 ++++++++++++++++++ 4 files changed, 239 insertions(+) diff --git a/src/google/adk/a2a/converters/event_converter.py b/src/google/adk/a2a/converters/event_converter.py index 2e6904b16bd..316addf36a5 100644 --- a/src/google/adk/a2a/converters/event_converter.py +++ b/src/google/adk/a2a/converters/event_converter.py @@ -51,6 +51,24 @@ # Logger logger = logging.getLogger("google_adk." + __name__) +_TERMINAL_TASK_STATES = frozenset({ + _compat.TS_COMPLETED, + _compat.TS_FAILED, + _compat.TS_CANCELED, +}) +"""Task states that mean the peer's turn is over.""" + + +def _is_terminal_task(a2a_task: Task) -> bool: + """Returns whether the task reports a state that ends the peer's turn. + + ``state`` is read defensively: 1.x tasks carry a protobuf ``TaskStatus`` + whose fields are not always reachable on stand-in objects, and an + unreadable state simply means we cannot claim the turn is over. + """ + status = getattr(a2a_task, "status", None) + return getattr(status, "state", None) in _TERMINAL_TASK_STATES + AdkEventToA2AEventsConverter = Callable[ [ @@ -256,6 +274,12 @@ def convert_a2a_task_to_event( event: Event = convert_a2a_message_to_event( message, author, invocation_context, part_converter=part_converter ) + if _is_terminal_task(a2a_task): + # See the note in ``to_adk_event.convert_a2a_task_to_event``: a + # terminal task is the end of the peer's turn, but the event holds + # the peer's tool activity, so ``is_final_response()`` would report + # False without this. + event.actions.skip_summarization = True return event except Exception as e: logger.error("Failed to convert A2A task message to event: %s", e) diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index e03f8a597c3..ebbe2fc618f 100644 --- a/src/google/adk/a2a/converters/to_adk_event.py +++ b/src/google/adk/a2a/converters/to_adk_event.py @@ -290,6 +290,19 @@ def _extract_genai_metadata( """ +_TERMINAL_TASK_STATES = frozenset({ + _compat.TS_COMPLETED, + _compat.TS_FAILED, + _compat.TS_CANCELED, +}) +"""Task states that mean the peer's turn is over. + +``input_required`` and ``auth_required`` are deliberately excluded: those pause +the turn to ask the caller for something, and they already reach +``is_final_response()`` through the mock function call built for them. +""" + + def _extract_event_actions(metadata: Any) -> EventActions: """Extracts ADK event actions from A2A metadata. @@ -534,6 +547,17 @@ def convert_a2a_task_to_event( ) ) + if a2a_task.status.state in _TERMINAL_TASK_STATES: + # A terminal task is the whole of the peer's turn, so the event carries + # the peer's tool activity alongside its closing text. Without this, + # `Event.is_final_response()` sees those function calls/responses and + # reports False, and a caller that closes the turn on that helper never + # closes it. `skip_summarization` is the existing signal for "this event + # is final despite carrying tool activity"; it is also already trusted + # from the peer (`_PEER_SETTABLE_ACTION_FIELDS`), so setting it here does + # not widen what an A2A response can influence. + event_actions.skip_summarization = True + return _create_event( output_parts, invocation_context, diff --git a/tests/unittests/a2a/converters/test_event_converter.py b/tests/unittests/a2a/converters/test_event_converter.py index 3e7a3658d6f..7fcc36292a8 100644 --- a/tests/unittests/a2a/converters/test_event_converter.py +++ b/tests/unittests/a2a/converters/test_event_converter.py @@ -739,6 +739,85 @@ def test_convert_a2a_task_to_event_with_artifacts_priority(self): assert called_message.role == _compat.ROLE_AGENT assert called_message.parts == [artifact_part] + @pytest.mark.parametrize( + "terminal_state", + [_compat.TS_COMPLETED, _compat.TS_FAILED, _compat.TS_CANCELED], + ) + def test_convert_a2a_task_to_event_terminal_task_is_final_response( + self, terminal_state + ): + """A terminal task carrying tool activity must read as a final response.""" + task = Task( + id="task-1", + context_id="context-1", + status=_compat.make_task_status( + terminal_state, timestamp="2024-01-01T00:00:00Z" + ), + artifacts=[ + _compat.make_artifact( + artifact_id="art-1", + artifact_type="message", + parts=[_compat.make_text_part("fr")], + ) + ], + ) + + def part_converter(part): + return [ + genai_types.Part( + function_response=genai_types.FunctionResponse( + id="call-1", name="divide", response={"quotient": 5} + ) + ) + ] + + result = convert_a2a_task_to_event( + task, + "test-author", + self.mock_invocation_context, + part_converter=part_converter, + ) + + assert len(result.get_function_responses()) == 1 + assert result.actions.skip_summarization is True + assert result.is_final_response() is True + + def test_convert_a2a_task_to_event_working_task_is_not_final(self): + """An in-flight task must keep reporting that the turn is still open.""" + task = Task( + id="task-1", + context_id="context-1", + status=_compat.make_task_status( + _compat.TS_WORKING, timestamp="2024-01-01T00:00:00Z" + ), + artifacts=[ + _compat.make_artifact( + artifact_id="art-1", + artifact_type="message", + parts=[_compat.make_text_part("fr")], + ) + ], + ) + + def part_converter(part): + return [ + genai_types.Part( + function_response=genai_types.FunctionResponse( + id="call-1", name="divide", response={"quotient": 5} + ) + ) + ] + + result = convert_a2a_task_to_event( + task, + "test-author", + self.mock_invocation_context, + part_converter=part_converter, + ) + + assert result.actions.skip_summarization is None + assert result.is_final_response() is False + def test_convert_a2a_task_to_event_with_status_message(self): """Test convert_a2a_task_to_event with status message (no artifacts).""" diff --git a/tests/unittests/a2a/converters/test_to_adk.py b/tests/unittests/a2a/converters/test_to_adk.py index ac1f05836b7..92472513349 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -62,6 +62,53 @@ def _make_a2a_part_for_test(metadata=None): return m +def _tool_activity_part_converter(part): + """Turns the "fc"/"fr" marker parts below into real tool activity.""" + text = _compat.part_text(part) + if text == "fc": + return [ + genai_types.Part( + function_call=genai_types.FunctionCall( + id="call-1", name="divide", args={"a": 10, "b": 0} + ) + ) + ] + if text == "fr": + return [ + genai_types.Part( + function_response=genai_types.FunctionResponse( + id="call-1", + name="divide", + response={"error": "cannot divide by zero"}, + ) + ) + ] + return [genai_types.Part.from_text(text=text)] + + +def _make_task_with_tool_activity(state): + """A non-streaming peer turn: narration, a tool call, its result, an answer. + + This is what `message/send` returns for a peer whose card leaves + `capabilities.streaming` at False, which is the default for `to_a2a(...)`. + """ + return Task( + id="task-1", + context_id="context-1", + status=_compat.make_task_status(state, timestamp="2024-01-01T00:00:00Z"), + artifacts=[ + _compat.make_artifact( + artifact_id=f"art-{index}", + artifact_type="message", + parts=[_compat.make_text_part(text)], + ) + for index, text in enumerate( + ["Let me divide that.", "fc", "fr", "You cannot divide by zero."] + ) + ], + ) + + class TestToAdk: """Test suite for to_adk functions.""" @@ -179,6 +226,71 @@ def test_convert_a2a_task_to_event_success(self): assert len(event.content.parts) == 1 assert event.content.parts[0] == mock_genai_part + @pytest.mark.parametrize( + "terminal_state", + [_compat.TS_COMPLETED, _compat.TS_FAILED, _compat.TS_CANCELED], + ) + def test_convert_a2a_task_to_event_terminal_task_is_final_response( + self, terminal_state + ): + """A terminal task carrying tool activity must read as a final response.""" + task = _make_task_with_tool_activity(terminal_state) + + event = convert_a2a_task_to_event( + task, + author="test-author", + invocation_context=self.mock_context, + part_converter=_tool_activity_part_converter, + ) + + # The peer's tool activity is still carried on the event ... + assert len(event.get_function_calls()) == 1 + assert len(event.get_function_responses()) == 1 + # ... but the turn is over, so the caller can detect the close. + assert event.actions.skip_summarization is True + assert event.is_final_response() is True + + @pytest.mark.parametrize( + "non_terminal_state", + [_compat.TS_SUBMITTED, _compat.TS_WORKING], + ) + def test_convert_a2a_task_to_event_non_terminal_task_is_not_final( + self, non_terminal_state + ): + """An in-flight task must keep reporting that the turn is still open.""" + task = _make_task_with_tool_activity(non_terminal_state) + + event = convert_a2a_task_to_event( + task, + author="test-author", + invocation_context=self.mock_context, + part_converter=_tool_activity_part_converter, + ) + + assert event.actions.skip_summarization is None + assert event.is_final_response() is False + + @pytest.mark.parametrize( + "pause_state", + [_compat.TS_INPUT_REQUIRED, _compat.TS_AUTH_REQUIRED], + ) + def test_convert_a2a_task_to_event_pause_state_left_untouched( + self, pause_state + ): + """input/auth-required already resolve via the mock call; leave them be.""" + task = _make_task_with_tool_activity(pause_state) + + event = convert_a2a_task_to_event( + task, + author="test-author", + invocation_context=self.mock_context, + part_converter=_tool_activity_part_converter, + ) + + assert event.actions.skip_summarization is None + assert event.long_running_tool_ids + assert event.is_final_response() is True + def test_convert_a2a_task_to_event_returns_action_only_event(self): """Test A2A task conversion returns action-only events.""" task = Task(