From 9a9b8af9b41e4ba90001ed9f0149d791ca539777 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 16 Oct 2026 21:49:21 +0800 Subject: [PATCH 1/2] fix(server): fail fast when an executor produces no events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a zero-event executor, message/send hung forever: the result was None and ActiveTask.get_task() awaited _task_created with no timeout, but _task_created is only set when a task event is processed, the task pre-existed at start(), or the producer failed — none of which happens on a silent success path (issue #1330). Per the approach endorsed on the issue, fail fast with InternalError when the subscribe loop finished without a task event, guarded by the new ActiveTask.task_created_is_set() accessor. The legacy handler's unreachable 'raise InternalError' guard is unchanged; making it reachable is a separate follow-up. Signed-off-by: andy --- src/a2a/server/agent_execution/active_task.py | 9 +++++ .../default_request_handler_v2.py | 8 +++++ .../test_default_request_handler_v2.py | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/a2a/server/agent_execution/active_task.py b/src/a2a/server/agent_execution/active_task.py index 5e3dab50f..6d0bfde69 100644 --- a/src/a2a/server/agent_execution/active_task.py +++ b/src/a2a/server/agent_execution/active_task.py @@ -1003,6 +1003,15 @@ async def _mark_task_as_failed(self, exception: Exception) -> Task | None: pass return task + def task_created_is_set(self) -> bool: + """Whether the initial task is available. + + True once a task event has been processed, the task pre-existed at + start(), or the producer failed — i.e. exactly when ``get_task()`` + is guaranteed not to block on ``_task_created``. + """ + return self._task_created.is_set() + async def get_task(self) -> Task: """Get task from db.""" await self._task_created.wait() diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index b135f529f..5d8aad0c5 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -444,6 +444,14 @@ async def on_message_send( # noqa: D102 # pushed after a Message. if result is None: + if not active_task.task_created_is_set(): + # The executor finished without emitting a single event: + # there is no task to fall back to, and awaiting + # get_task() would block forever on _task_created + # (issue #1330). + raise InternalError( + message='Agent execution produced no events' + ) logger.debug('Missing result for task %s', request_context.task_id) result = await active_task.get_task() diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index b30fdc695..0fb50a609 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -2776,3 +2776,39 @@ async def test_on_message_send_stream_message_response_releases_active_task() -> assert [type(event) for event in events] == [Message] assert await _wait_until_released(handler, baseline) == set() assert handler._active_task_registry._active_tasks == {} + + +class SilentExecutor(AgentExecutor): + """Returns normally without producing any event (issue #1330).""" + + async def execute(self, context: RequestContext, event_queue: EventQueue): + return None + + async def cancel(self, context: RequestContext, event_queue: EventQueue): + pass + + +@pytest.mark.asyncio +async def test_on_message_send_raises_when_agent_produces_no_events(): + """A silent executor must fail fast with InternalError instead of + hanging forever on the uninitialized-task wait (#1330).""" + request_handler = DefaultRequestHandlerV2( + agent_executor=SilentExecutor(), + task_store=InMemoryTaskStore(), + agent_card=create_default_agent_card(), + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg_silent', + parts=[Part(text='Hi')], + ), + configuration=SendMessageConfiguration( + accepted_output_modes=['text/plain'] + ), + ) + + with pytest.raises(InternalError, match='no events'): + await request_handler.on_message_send( + params, create_server_call_context() + ) From 2079ce414a8a556f077610766ec4d583698992fb Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 4 Nov 2026 00:13:35 +0800 Subject: [PATCH 2/2] refactor(server): address review on the silent-executor guard Mapping each review item: - active_task.py: turn task_created_is_set() into a task_created property. - active_task.py: shorten the property docstring to one sentence. - default_request_handler_v2.py: shorten the guard comment to one sentence. - default_request_handler_v2.py: raise InvalidAgentResponseError instead of InternalError, matching how this handler already treats a misbehaving agent (it is the type used for task-id mismatch and multiple-Message streams); the test asserts the new type. Signed-off-by: andy --- src/a2a/server/agent_execution/active_task.py | 10 +++------- .../request_handlers/default_request_handler_v2.py | 10 ++++------ .../test_default_request_handler_v2.py | 5 ++--- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/a2a/server/agent_execution/active_task.py b/src/a2a/server/agent_execution/active_task.py index 6d0bfde69..6a9f27ed3 100644 --- a/src/a2a/server/agent_execution/active_task.py +++ b/src/a2a/server/agent_execution/active_task.py @@ -1003,13 +1003,9 @@ async def _mark_task_as_failed(self, exception: Exception) -> Task | None: pass return task - def task_created_is_set(self) -> bool: - """Whether the initial task is available. - - True once a task event has been processed, the task pre-existed at - start(), or the producer failed — i.e. exactly when ``get_task()`` - is guaranteed not to block on ``_task_created``. - """ + @property + def task_created(self) -> bool: + """Whether a task exists, so ``get_task()`` will not block.""" return self._task_created.is_set() async def get_task(self) -> Task: diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index 5d8aad0c5..90716240a 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -54,6 +54,7 @@ from a2a.utils.errors import ( ExtendedAgentCardNotConfiguredError, InternalError, + InvalidAgentResponseError, InvalidParamsError, PushNotificationNotSupportedError, TaskNotCancelableError, @@ -444,12 +445,9 @@ async def on_message_send( # noqa: D102 # pushed after a Message. if result is None: - if not active_task.task_created_is_set(): - # The executor finished without emitting a single event: - # there is no task to fall back to, and awaiting - # get_task() would block forever on _task_created - # (issue #1330). - raise InternalError( + if not active_task.task_created: + # A silent executor means get_task() would block forever. + raise InvalidAgentResponseError( message='Agent execution produced no events' ) logger.debug('Missing result for task %s', request_context.task_id) diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index 0fb50a609..b010b71b6 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -2790,8 +2790,7 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue): @pytest.mark.asyncio async def test_on_message_send_raises_when_agent_produces_no_events(): - """A silent executor must fail fast with InternalError instead of - hanging forever on the uninitialized-task wait (#1330).""" + """A silent executor must fail fast instead of hanging forever (#1330).""" request_handler = DefaultRequestHandlerV2( agent_executor=SilentExecutor(), task_store=InMemoryTaskStore(), @@ -2808,7 +2807,7 @@ async def test_on_message_send_raises_when_agent_produces_no_events(): ), ) - with pytest.raises(InternalError, match='no events'): + with pytest.raises(InvalidAgentResponseError, match='no events'): await request_handler.on_message_send( params, create_server_call_context() )