Skip to content

Commit ef5bbcf

Browse files
DeanChensjcopybara-github
authored andcommitted
feat: add optional abort_signal support to Runner, Workflow, and Nodes
Support passing an asyncio.Event abort_signal to Runner.run_async to terminate agent and graph workflow execution loops early upon client disconnect or manual stop events. Closes #4796 Co-authored-by: Shangjie Chen <[email protected]> PiperOrigin-RevId: 989843613
1 parent 31e5358 commit ef5bbcf

11 files changed

Lines changed: 947 additions & 65 deletions

File tree

‎src/google/adk/flows/llm_flows/base_llm_flow.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,19 @@ async def run_async(
324324
) -> AsyncGenerator[Event, None]:
325325
"""Runs the flow."""
326326
while True:
327+
# `is True` rather than truthiness: tests drive agents with mocked
328+
# contexts, whose is_aborted is a truthy Mock rather than False.
329+
if invocation_context.is_aborted is True:
330+
break
327331
last_event = None
328332
async with Aclosing(self._run_one_step_async(invocation_context)) as agen:
329333
async for event in agen:
330334
last_event = event
331335
yield event
336+
if invocation_context.is_aborted is True:
337+
break
338+
if invocation_context.is_aborted is True:
339+
break
332340
if not last_event or last_event.is_final_response() or last_event.partial:
333341
if last_event and last_event.partial:
334342
logger.warning('The last event is partial, which is not expected.')

‎src/google/adk/runners.py‎

Lines changed: 130 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,7 @@ async def _run_node_async(
550550
yield_user_message: bool = False,
551551
node: BaseNode | None = None,
552552
session: Optional[Session] = None,
553+
abort_signal: Optional[asyncio.Event] = None,
553554
) -> AsyncGenerator[Event, None]:
554555
"""Run a BaseNode through NodeRunner.
555556
@@ -569,6 +570,7 @@ async def _run_node_async(
569570
yield_user_message=yield_user_message,
570571
node=node,
571572
session=session,
573+
abort_signal=abort_signal,
572574
)
573575
) as agen:
574576
async for event in agen:
@@ -755,52 +757,99 @@ async def _consume_event_queue(
755757
"""Consume events from ic._event_queue until done_sentinel."""
756758
event_queue: asyncio.Queue[_EventQueueItem] | None = ic._event_queue
757759
assert event_queue is not None
758-
while True:
759-
event_or_done, processed_signal = await event_queue.get()
760-
if event_or_done is done_sentinel:
761-
break
762-
if not isinstance(event_or_done, Event):
763-
raise TypeError(
764-
f'Unexpected node event queue item: {type(event_or_done).__name__}'
765-
)
766-
event = event_or_done
767-
output_event = await self._process_event_with_plugin_callbacks(
768-
invocation_context=ic,
769-
event=event,
770-
)
760+
if ic.is_aborted and event_queue.empty():
761+
return
762+
abort_task: asyncio.Task[bool] | None = None
763+
get_task: asyncio.Task[_EventQueueItem] | None = None
764+
try:
765+
while True:
766+
# Anything still queued after abort is dropped rather than drained,
767+
# except an event that a node produced before checking abort in the
768+
# same turn. A producer parked in _enqueue_event is released by
769+
# _cleanup_root_task, not by this loop.
770+
if ic.is_aborted and event_queue.empty():
771+
break
772+
if not event_queue.empty():
773+
event_or_done, processed_signal = event_queue.get_nowait()
774+
else:
775+
if abort_task is None or abort_task.done():
776+
abort_task = asyncio.create_task(
777+
ic._abort_signal.wait() # pylint: disable=protected-access
778+
)
779+
get_task = asyncio.create_task(event_queue.get())
780+
done, _ = await asyncio.wait(
781+
[get_task, abort_task], return_when=asyncio.FIRST_COMPLETED
782+
)
783+
if not get_task.done():
784+
# Let get_task finish its step if event_queue.put() already resolved
785+
# its internal getter future in the same event-loop turn as abort().
786+
await asyncio.sleep(0)
787+
if not get_task.done():
788+
break
789+
event_or_done, processed_signal = get_task.result()
790+
get_task = None
791+
if event_or_done is done_sentinel:
792+
break
793+
if not isinstance(event_or_done, Event):
794+
raise TypeError(
795+
'Unexpected node event queue item:'
796+
f' {type(event_or_done).__name__}'
797+
)
798+
event = event_or_done
771799

772-
# When an LlmAgent node uses ``message_as_output`` (no
773-
# ``output_schema``), the wrapper sets both ``event.content``
774-
# (the model's text) AND ``event.output`` (the same text) to
775-
# signal that the message IS the node's output. Clear
776-
# ``event.output`` on a copy here so downstream renderers don't
777-
# surface the same text twice. Task-mode agents set
778-
# ``event.output`` from the ``finish_task`` FC args without
779-
# ``message_as_output``, so this clearing doesn't affect them.
780-
if not output_event.partial:
781-
if (
782-
output_event.node_info
783-
and output_event.node_info.message_as_output
784-
and output_event.content is not None
785-
):
786-
output_event = output_event.model_copy()
787-
output_event.output = None
788-
await self.session_service.append_event(
789-
session=ic.session, event=output_event
800+
async def _process_and_clear(invocation_context, ev_arg):
801+
nonlocal event
802+
ev = await self._process_event_with_plugin_callbacks(
803+
invocation_context=invocation_context,
804+
event=ev_arg,
805+
)
806+
if not ev.partial:
807+
if (
808+
ev.node_info
809+
and ev.node_info.message_as_output
810+
and ev.content is not None
811+
):
812+
ev = ev.model_copy()
813+
ev.output = None
814+
event = ev
815+
return ev
816+
817+
output_event = await _process_and_clear(
818+
invocation_context=ic,
819+
ev_arg=event,
790820
)
791-
yield output_event
792821

793-
if isinstance(processed_signal, asyncio.Event):
794-
processed_signal.set()
822+
if not event.partial:
823+
await self.session_service.append_event(
824+
session=ic.session, event=output_event
825+
)
826+
yield output_event
827+
828+
if isinstance(processed_signal, asyncio.Event):
829+
processed_signal.set()
830+
finally:
831+
pending_tasks = [
832+
t for t in (get_task, abort_task) if t is not None and not t.done()
833+
]
834+
for t in pending_tasks:
835+
t.cancel()
836+
if pending_tasks:
837+
await asyncio.wait(pending_tasks)
795838

796839
async def _cleanup_root_task(
797-
self, task: asyncio.Task[None], node_name: str
840+
self,
841+
task: asyncio.Task[None],
842+
node_name: str,
798843
) -> None:
799844
"""Cancel the root task if still running, then await it.
800845
801846
The task may still be running if the caller stopped iterating
802847
early (e.g., break in async for). In that case we must cancel
803848
to avoid a leaked task.
849+
850+
Args:
851+
task: The root task to cancel and await.
852+
node_name: The name of the root node, used for logging.
804853
"""
805854
cancelled_by_cleanup = False
806855
if not task.done():
@@ -1037,6 +1086,7 @@ async def run_async(
10371086
state_delta: Optional[dict[str, Any]] = None,
10381087
run_config: Optional[RunConfig] = None,
10391088
yield_user_message: bool = False,
1089+
abort_signal: Optional[asyncio.Event] = None,
10401090
) -> AsyncGenerator[Event, None]:
10411091
"""Main entry method to run the agent in this runner.
10421092
@@ -1056,6 +1106,7 @@ async def run_async(
10561106
run_config: The run config for the agent.
10571107
yield_user_message: If True, yield the user message event before
10581108
agent/node events.
1109+
abort_signal: Optional asyncio.Event to cancel the invocation.
10591110
10601111
Yields:
10611112
The events generated by the agent.
@@ -1144,6 +1195,7 @@ async def run_async(
11441195
yield_user_message=yield_user_message,
11451196
node=agent_to_run,
11461197
session=session,
1198+
abort_signal=abort_signal,
11471199
)
11481200
) as agen:
11491201
async for event in agen:
@@ -1164,6 +1216,7 @@ async def run_async(
11641216
state_delta=state_delta,
11651217
run_config=run_config,
11661218
yield_user_message=yield_user_message,
1219+
abort_signal=abort_signal,
11671220
)
11681221
) as agen:
11691222
async for event in agen:
@@ -1213,6 +1266,7 @@ async def _run_with_trace(
12131266
run_config=run_config,
12141267
state_delta=state_delta,
12151268
invocation_id=invocation_id,
1269+
abort_signal=abort_signal,
12161270
)
12171271
else:
12181272
invocation_id = self._resolve_invocation_id(
@@ -1228,6 +1282,7 @@ async def _run_with_trace(
12281282
new_message=new_message,
12291283
run_config=run_config,
12301284
state_delta=state_delta,
1285+
abort_signal=abort_signal,
12311286
)
12321287
else:
12331288
invocation_context = (
@@ -1237,6 +1292,7 @@ async def _run_with_trace(
12371292
invocation_id=invocation_id,
12381293
run_config=run_config,
12391294
state_delta=state_delta,
1295+
abort_signal=abort_signal,
12401296
)
12411297
)
12421298
active_agent = invocation_context.agent
@@ -1249,15 +1305,21 @@ async def _run_with_trace(
12491305
# already final.
12501306
return
12511307

1308+
invocation_context._abort_state.loop = asyncio.get_running_loop() # pylint: disable=protected-access
1309+
12521310
async def execute(
12531311
ctx: InvocationContext,
12541312
) -> AsyncGenerator[Event, None]:
1313+
if ctx.is_aborted:
1314+
return
12551315
active_agent = ctx.agent
12561316
if not isinstance(active_agent, BaseAgent):
12571317
raise RuntimeError('Agent execution has no active BaseAgent.')
12581318
async with aclosing(active_agent.run_async(ctx)) as agen:
12591319
async for event in agen:
12601320
yield event
1321+
if ctx.is_aborted:
1322+
break
12611323

12621324
async with aclosing(
12631325
_with_caller_context(
@@ -1272,14 +1334,24 @@ async def execute(
12721334
) as agen:
12731335
async for event in agen:
12741336
yield event
1337+
if invocation_context.is_aborted:
1338+
break
12751339
# Run compaction after all events are yielded from the agent.
12761340
# (We don't compact in the middle of an invocation, we only compact at
12771341
# the end of an invocation.)
1278-
await self._run_post_invocation_compaction(
1279-
session=invocation_context.session,
1280-
skip_token_compaction=(invocation_context.token_compaction_checked),
1281-
)
1342+
if not invocation_context.is_aborted:
1343+
await self._run_post_invocation_compaction(
1344+
session=invocation_context.session,
1345+
skip_token_compaction=(
1346+
invocation_context.token_compaction_checked
1347+
),
1348+
)
12821349

1350+
# For BaseAgent root agents running via _run_with_trace, events flow
1351+
# through _run_with_trace which breaks cleanly upon abort.
1352+
# Note that BaseNode/Workflow root targets are routed earlier via
1353+
# _run_node_async, where Workflow and NodeRunner manage task cancellation
1354+
# and queue-draining internally.
12831355
async with aclosing(_run_with_trace(new_message, invocation_id)) as agen:
12841356
async for event in agen:
12851357
yield event
@@ -1821,6 +1893,7 @@ async def _setup_context_for_new_invocation(
18211893
run_config: RunConfig,
18221894
state_delta: Optional[dict[str, Any]],
18231895
invocation_id: Optional[str] = None,
1896+
abort_signal: Optional[asyncio.Event] = None,
18241897
) -> InvocationContext:
18251898
"""Sets up the context for a new invocation.
18261899
@@ -1830,6 +1903,7 @@ async def _setup_context_for_new_invocation(
18301903
run_config: The run config of the agent.
18311904
state_delta: Optional state changes to apply to the session.
18321905
invocation_id: Optional invocation identifier.
1906+
abort_signal: Optional abort signal to cancel this invocation.
18331907
18341908
Returns:
18351909
The invocation context for the new invocation.
@@ -1841,6 +1915,11 @@ async def _setup_context_for_new_invocation(
18411915
run_config=run_config,
18421916
invocation_id=invocation_id,
18431917
)
1918+
if abort_signal is not None:
1919+
# Attached here rather than passed to `_new_invocation_context`, whose
1920+
# signature subclasses override. Safe at this point because nothing has
1921+
# derived a sub-context from this one yet.
1922+
invocation_context._attach_abort_signal(abort_signal)
18441923
# Step 2: Handle new message, by running callbacks and appending to
18451924
# session.
18461925
await self._handle_new_message(
@@ -1868,6 +1947,7 @@ async def _setup_context_for_resumed_invocation(
18681947
invocation_id: str,
18691948
run_config: RunConfig,
18701949
state_delta: Optional[dict[str, Any]],
1950+
abort_signal: Optional[asyncio.Event] = None,
18711951
) -> InvocationContext:
18721952
"""Sets up the context for a resumed invocation.
18731953
@@ -1877,6 +1957,7 @@ async def _setup_context_for_resumed_invocation(
18771957
invocation_id: The invocation id to resume.
18781958
run_config: The run config of the agent.
18791959
state_delta: Optional state changes to apply to the session.
1960+
abort_signal: Optional abort signal to cancel this invocation.
18801961
18811962
Returns:
18821963
The invocation context for the resumed invocation.
@@ -1903,6 +1984,10 @@ async def _setup_context_for_resumed_invocation(
19031984
run_config=run_config,
19041985
invocation_id=invocation_id,
19051986
)
1987+
if abort_signal is not None:
1988+
# See `_setup_context_for_new_invocation` for why this is attached
1989+
# after construction rather than passed to the factory.
1990+
invocation_context._attach_abort_signal(abort_signal)
19061991
# Step 3: Maybe handle new message.
19071992
if new_message:
19081993
await self._handle_new_message(
@@ -1955,6 +2040,12 @@ def _new_invocation_context(
19552040
) -> InvocationContext:
19562041
"""Creates a new invocation context.
19572042
2043+
This is an extension point that subclasses override to build their own
2044+
context type, so its signature is kept stable. A caller-supplied abort
2045+
signal is attached to the returned context by the caller rather than
2046+
threaded through here, because adding a parameter would raise TypeError in
2047+
every existing override.
2048+
19582049
Args:
19592050
session: The session for the context.
19602051
invocation_id: The invocation id for the context.

‎src/google/adk/tools/agent_tool.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import asyncio
1718
import json
1819
from typing import Any
1920
from typing import Optional
@@ -314,6 +315,13 @@ async def run_async(
314315
update={'streaming_mode': StreamingMode.NONE}
315316
)
316317

318+
# Aborting the caller also aborts the wrapped agent. The isinstance check
319+
# is intentional: tests may hand in a mocked caller context whose signal is
320+
# a Mock, and the nested runner would read that as already aborted.
321+
abort_signal = invocation_context._abort_signal
322+
if not isinstance(abort_signal, asyncio.Event):
323+
abort_signal = None
324+
317325
last_content = None
318326
last_error_message = None
319327
last_grounding_metadata = None
@@ -323,6 +331,7 @@ async def run_async(
323331
session_id=session.id,
324332
new_message=content,
325333
run_config=nested_run_config,
334+
abort_signal=abort_signal,
326335
)
327336
) as agen:
328337
async for event in agen:

0 commit comments

Comments
 (0)