4949from .auth .credential_service .base_credential_service import BaseCredentialService
5050from .errors ._stale_session_error import StaleSessionError
5151from .errors .session_not_found_error import SessionNotFoundError
52+ from .events ._abort_events import _build_abort_events
53+ from .events ._abort_events import _is_abort_event
5254from .events ._internal_metadata import internal_metadata
5355from .events ._internal_metadata import without_internal_metadata
5456from .events ._rewind_events import _apply_rewinds
@@ -107,10 +109,11 @@ def _find_active_task_scope(session: Session) -> Optional[tuple[str, str]]:
107109 'result' key matching ``FINISH_TASK_SUCCESS_RESULT`` or
108110 ``FINISH_TASK_ERROR_RESULT``. A FunctionResponse containing an 'error' key
109111 (indicating a tool validation failure) does NOT close the scope: the task
110- agent is still active, will see the validation error, and retry. Walking
111- backward, the first non-empty scope we encounter that hasn't been closed by a
112- later successful or failed terminal ``finish_task`` is the paused task
113- awaiting the user's next reply.
112+ agent is still active, will see the validation error, and retry. Scopes whose
113+ invocation was aborted are also closed, since the task agent was cancelled
114+ without calling ``finish_task``. Walking backward, the first non-empty scope
115+ we encounter that hasn't been closed is the paused task awaiting the user's
116+ next reply.
114117
115118 Used by ``Runner._append_user_event`` to scope the new user message
116119 to that task agent's view.
@@ -125,7 +128,10 @@ def _find_active_task_scope(session: Session) -> Optional[tuple[str, str]]:
125128 # the older success FR, falsely indicating the scope is still active.
126129 live_events = _apply_rewinds (session .events )
127130 finished_scopes : set [str ] = set ()
131+ aborted_invocations : set [str ] = set ()
128132 for event in live_events :
133+ if _is_abort_event (event ):
134+ aborted_invocations .add (event .invocation_id )
129135 scope = event .isolation_scope
130136 if not scope :
131137 continue
@@ -146,7 +152,10 @@ def _find_active_task_scope(session: Session) -> Optional[tuple[str, str]]:
146152 scope = event .isolation_scope
147153 if not scope :
148154 continue
149- if scope not in finished_scopes :
155+ if (
156+ scope not in finished_scopes
157+ and event .invocation_id not in aborted_invocations
158+ ):
150159 return scope , event .invocation_id
151160 return None
152161
@@ -879,6 +888,27 @@ async def _cleanup_root_task(
879888 logger .error ('Root node %s failed.' , node_name , exc_info = True )
880889 raise
881890
891+ async def _synthesize_abort_events_if_needed (
892+ self , ic : InvocationContext
893+ ) -> list [Event ]:
894+ """Seals an aborted invocation in session history, at most once.
895+
896+ Returns the synthetic events after plugin processing and persistence.
897+ """
898+ if ic ._abort_state .event_synthesized or ic .session is None : # pylint: disable=protected-access
899+ return []
900+ ic ._abort_state .event_synthesized = True # pylint: disable=protected-access
901+ abort_events = _build_abort_events (
902+ ic .session .events ,
903+ invocation_id = ic .invocation_id ,
904+ root_agent_name = self .agent .name ,
905+ branch = ic .branch ,
906+ )
907+ return [
908+ await self ._process_and_append_event (event = e , invocation_context = ic )
909+ for e in abort_events
910+ ]
911+
882912 async def _run_post_invocation_compaction (
883913 self ,
884914 * ,
@@ -1339,12 +1369,10 @@ async def execute(
13391369 ) as agen :
13401370 async for event in agen :
13411371 yield event
1342- if invocation_context .is_aborted :
1343- break
1344- # Run compaction after all events are yielded from the agent.
1345- # (We don't compact in the middle of an invocation, we only compact at
1346- # the end of an invocation.)
13471372 if not invocation_context .is_aborted :
1373+ # Run compaction after all events are yielded from the agent.
1374+ # (We don't compact in the middle of an invocation, we only compact at
1375+ # the end of an invocation.)
13481376 await self ._run_post_invocation_compaction (
13491377 session = invocation_context .session ,
13501378 skip_token_compaction = (
@@ -1489,6 +1517,34 @@ async def _process_event_with_plugin_callbacks(
14891517 run_config = invocation_context .run_config ,
14901518 )
14911519
1520+ async def _process_and_append_event (
1521+ self ,
1522+ event : Event ,
1523+ invocation_context : InvocationContext ,
1524+ * ,
1525+ is_live_call : bool = False ,
1526+ ) -> Event :
1527+ """Applies custom metadata, runs on_event callbacks, and appends to session."""
1528+ output_event = await self ._process_event_with_plugin_callbacks (
1529+ invocation_context = invocation_context ,
1530+ event = event ,
1531+ )
1532+
1533+ if is_live_call :
1534+ if output_event .partial is not True and self ._should_append_event (
1535+ output_event , is_live_call
1536+ ):
1537+ logger .debug ('Appending live event: %s' , output_event )
1538+ await self .session_service .append_event (
1539+ session = invocation_context .session , event = output_event
1540+ )
1541+ else :
1542+ if output_event .partial is not True :
1543+ await self .session_service .append_event (
1544+ session = invocation_context .session , event = output_event
1545+ )
1546+ return output_event
1547+
14921548 async def _exec_with_plugin (
14931549 self ,
14941550 invocation_context : InvocationContext ,
@@ -1538,30 +1594,18 @@ async def _exec_with_plugin(
15381594 # Step 2: Otherwise continue with normal execution
15391595 async with aclosing (execute_fn (invocation_context )) as agen :
15401596 async for event in agen :
1541- # Step 3: Run the on_event callbacks before persisting so callback
1542- # changes are stored in the session and match the streamed event.
1543- output_event = await self ._process_event_with_plugin_callbacks (
1544- invocation_context = invocation_context ,
1597+ output_event = await self ._process_and_append_event (
15451598 event = event ,
1599+ invocation_context = invocation_context ,
1600+ is_live_call = is_live_call ,
15461601 )
1547-
1548- if is_live_call :
1549- # Skip partial transcriptions for Live
1550- if (
1551- output_event .partial is not True
1552- and self ._should_append_event (output_event , is_live_call )
1553- ):
1554- logger .debug ('Appending live event: %s' , output_event )
1555- await self .session_service .append_event (
1556- session = invocation_context .session , event = output_event
1557- )
1558- else :
1559- if output_event .partial is not True :
1560- await self .session_service .append_event (
1561- session = invocation_context .session , event = output_event
1562- )
1563-
15641602 yield output_event
1603+ if not is_live_call and invocation_context .is_aborted :
1604+ abort_events = await self ._synthesize_abort_events_if_needed (
1605+ invocation_context
1606+ )
1607+ for abort_event in abort_events :
1608+ yield abort_event
15651609 except GeneratorExit :
15661610 # Early generator close is treated as a clean completion.
15671611 closing_early = True
@@ -1584,6 +1628,17 @@ async def _exec_with_plugin(
15841628 run_error = e
15851629 raise
15861630 finally :
1631+ if not is_live_call and invocation_context .is_aborted :
1632+ # Best-effort: only reached on early close or error, where raising
1633+ # would mask the in-flight exception and skip after_run.
1634+ try :
1635+ await self ._synthesize_abort_events_if_needed (invocation_context )
1636+ except Exception : # pylint: disable=broad-exception-caught
1637+ logger .error (
1638+ 'Failed to seal aborted invocation %s.' ,
1639+ invocation_context .invocation_id ,
1640+ exc_info = True ,
1641+ )
15871642 # Step 4: Run after_run callbacks on successful completion or early exit.
15881643 if run_error is None :
15891644 try :
0 commit comments