@@ -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.
0 commit comments