From a9d501c2cbd44957f27ad79b2d8b0a9b70cb29eb Mon Sep 17 00:00:00 2001 From: 1wos <1wosomm1@gmail.com> Date: Sun, 27 Sep 2026 22:57:42 +0900 Subject: [PATCH] fix(a2a): stop sending exception text to the remote peer Both executors put str(e) into the failed task status message, so any file path, hostname or credential carried by an exception raised inside the run reached the peer that made the request. Log the exception in full against a short error id and send the peer a fixed summary carrying only that id. Set ADK_A2A_DEBUG_ERRORS=1 to append the exception text when debugging locally. The existing impl test asserted the exception text was present; it now asserts it is absent. Fixes #7315 --- .../adk/a2a/executor/a2a_agent_executor.py | 11 ++++- .../a2a/executor/a2a_agent_executor_impl.py | 11 ++++- src/google/adk/a2a/executor/utils.py | 15 +++++++ .../a2a/executor/test_a2a_agent_executor.py | 20 +++++++++ .../executor/test_a2a_agent_executor_impl.py | 45 +++++++++++++++++-- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/google/adk/a2a/executor/a2a_agent_executor.py b/src/google/adk/a2a/executor/a2a_agent_executor.py index 9b655c39412..a06b8098f78 100644 --- a/src/google/adk/a2a/executor/a2a_agent_executor.py +++ b/src/google/adk/a2a/executor/a2a_agent_executor.py @@ -45,6 +45,7 @@ from .utils import execute_after_agent_interceptors from .utils import execute_after_event_interceptors from .utils import execute_before_agent_interceptors +from .utils import failure_summary logger = logging.getLogger('google_adk.' + __name__) @@ -152,7 +153,13 @@ async def execute( try: await self._handle_request(context, event_queue) except Exception as e: - logger.error('Error handling A2A request: %s', e, exc_info=True) + error_id, peer_text = failure_summary(e) + logger.error( + 'Error handling A2A request [error_id=%s]: %s', + error_id, + e, + exc_info=True, + ) # Publish failure event try: await event_queue.enqueue_event( @@ -164,7 +171,7 @@ async def execute( message=Message( message_id=platform_uuid.new_uuid(), role=_compat.ROLE_AGENT, - parts=[_compat.make_text_part(str(e))], + parts=[_compat.make_text_part(peer_text)], ), ), final=True, diff --git a/src/google/adk/a2a/executor/a2a_agent_executor_impl.py b/src/google/adk/a2a/executor/a2a_agent_executor_impl.py index c34ecf8d03d..d19a71d4c9d 100644 --- a/src/google/adk/a2a/executor/a2a_agent_executor_impl.py +++ b/src/google/adk/a2a/executor/a2a_agent_executor_impl.py @@ -46,6 +46,7 @@ from .utils import execute_after_agent_interceptors from .utils import execute_after_event_interceptors from .utils import execute_before_agent_interceptors +from .utils import failure_summary logger = logging.getLogger('google_adk.' + __name__) @@ -153,7 +154,13 @@ async def execute( run_request, ) except Exception as e: - logger.error('Error handling A2A request: %s', e, exc_info=True) + error_id, peer_text = failure_summary(e) + logger.error( + 'Error handling A2A request [error_id=%s]: %s', + error_id, + e, + exc_info=True, + ) # Publish failure event try: await event_queue.enqueue_event( @@ -165,7 +172,7 @@ async def execute( message=Message( message_id=str(uuid.uuid4()), role=_compat.ROLE_AGENT, - parts=[_compat.make_text_part(str(e))], + parts=[_compat.make_text_part(peer_text)], ), ), final=True, diff --git a/src/google/adk/a2a/executor/utils.py b/src/google/adk/a2a/executor/utils.py index 5f284ba6d13..11d833fcce9 100644 --- a/src/google/adk/a2a/executor/utils.py +++ b/src/google/adk/a2a/executor/utils.py @@ -13,7 +13,9 @@ # limitations under the License. from __future__ import annotations +import os from typing import Optional +import uuid from a2a.server.agent_execution.context import RequestContext from a2a.server.events import Event as A2AEvent @@ -28,6 +30,19 @@ from .executor_context import ExecutorContext +def failure_summary(error: Exception) -> tuple[str, str]: + """Returns an error id and the peer-facing text for an execution failure. + + The exception text can carry paths, hostnames and credentials, so the peer + gets the id only. `ADK_A2A_DEBUG_ERRORS=1` appends the exception text. + """ + error_id = uuid.uuid4().hex[:8] + text = f'Agent execution failed. (error_id: {error_id})' + if os.environ.get('ADK_A2A_DEBUG_ERRORS') == '1': + text = f'{text}: {type(error).__name__}: {error}' + return error_id, text + + async def _enqueue_canceled_task_event( context: RequestContext, event_queue: EventQueue, diff --git a/tests/unittests/a2a/executor/test_a2a_agent_executor.py b/tests/unittests/a2a/executor/test_a2a_agent_executor.py index ad52123d50d..e3cd5c7b3bf 100644 --- a/tests/unittests/a2a/executor/test_a2a_agent_executor.py +++ b/tests/unittests/a2a/executor/test_a2a_agent_executor.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import re from unittest.mock import AsyncMock from unittest.mock import Mock from unittest.mock import patch @@ -800,6 +801,25 @@ async def test_execute_with_exception_handling(self): assert failure_event.status.state == _compat.TS_FAILED _assert_final(failure_event) + @pytest.mark.asyncio + async def test_execute_failure_message_omits_exception_text(self): + """The peer gets a fixed summary and an id, never the exception text.""" + self.mock_context.task_id = "test-task-id" + self.mock_context.current_task = None + self.mock_request_converter.side_effect = FileNotFoundError( + 2, "No such file or directory", "/srv/secrets/sa-key.json" + ) + + await self.executor.execute(self.mock_context, self.mock_event_queue) + + failure_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0] + part = failure_event.status.message.parts[0] + text = part.text if _compat.IS_A2A_V1 else part.root.text + assert "/srv/secrets/sa-key.json" not in text + assert re.fullmatch( + r"Agent execution failed\. \(error_id: [0-9a-f]{8}\)", text + ) + @pytest.mark.asyncio async def test_handle_request_with_aggregator_message(self): """Test that the final task status event includes message from aggregator.""" diff --git a/tests/unittests/a2a/executor/test_a2a_agent_executor_impl.py b/tests/unittests/a2a/executor/test_a2a_agent_executor_impl.py index d4c222fc6cc..563fbd6a4c1 100644 --- a/tests/unittests/a2a/executor/test_a2a_agent_executor_impl.py +++ b/tests/unittests/a2a/executor/test_a2a_agent_executor_impl.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import re from unittest.mock import AsyncMock from unittest.mock import Mock from unittest.mock import patch @@ -500,10 +501,46 @@ async def test_execute_with_exception_handling(self): assert failure_event.status.state == _compat.TS_FAILED _assert_final(failure_event) _failure_part = failure_event.status.message.parts[0] - if _compat.IS_A2A_V1: - assert "Test error" in _failure_part.text - else: - assert "Test error" in _failure_part.root.text + _text = _failure_part.text if _compat.IS_A2A_V1 else _failure_part.root.text + assert "Agent execution failed." in _text + assert "Test error" not in _text + + @pytest.mark.asyncio + async def test_execute_failure_message_omits_exception_text(self): + """The peer gets a fixed summary and an id, never the exception text.""" + self.mock_context.task_id = "test-task-id" + self.mock_context.current_task = None + self.mock_request_converter.side_effect = FileNotFoundError( + 2, "No such file or directory", "/srv/secrets/sa-key.json" + ) + + await self.executor.execute(self.mock_context, self.mock_event_queue) + + failure_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0] + part = failure_event.status.message.parts[0] + text = part.text if _compat.IS_A2A_V1 else part.root.text + assert "/srv/secrets/sa-key.json" not in text + assert re.fullmatch( + r"Agent execution failed\. \(error_id: [0-9a-f]{8}\)", text + ) + + @pytest.mark.asyncio + async def test_execute_failure_message_includes_detail_when_opted_in( + self, monkeypatch + ): + """`ADK_A2A_DEBUG_ERRORS=1` puts the exception text back.""" + monkeypatch.setenv("ADK_A2A_DEBUG_ERRORS", "1") + self.mock_context.task_id = "test-task-id" + self.mock_context.current_task = None + self.mock_request_converter.side_effect = Exception("Test error") + + await self.executor.execute(self.mock_context, self.mock_event_queue) + + failure_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0] + part = failure_event.status.message.parts[0] + text = part.text if _compat.IS_A2A_V1 else part.root.text + assert "Agent execution failed." in text + assert "Test error" in text @pytest.mark.asyncio async def test_handle_request_with_non_working_state(self):