@@ -474,70 +474,79 @@ async def _execute_sequential(
474474 finalized_list : list [_FinalizedOutcome ] = []
475475 messages : list [ToolResultMessage ] = []
476476
477- for idx , tc in enumerate (tool_calls ):
478- if preresolved is not None :
479- rtc , was_resolved , resolver_error = preresolved [idx ]
480- else :
481- # Lazy: resolve only when this call's turn comes, after every
482- # earlier call in the batch has fully executed.
483- rtc , was_resolved , resolver_error = await _resolve_tool_call (
484- tc , context , resolve_tool_call , signal
485- )
486-
487- await emit_event (
488- emit_fn ,
489- ToolExecutionStartEvent (
490- tool_call_id = rtc .id , tool_name = rtc .name , args = rtc .arguments
491- ),
492- )
493-
494- preparation = resolver_error or await _prepare_tool_call (
495- context ,
496- assistant_message ,
497- rtc ,
498- before_tool_call ,
499- signal ,
500- resolved = was_resolved ,
501- )
477+ try :
478+ for idx , tc in enumerate (tool_calls ):
479+ if preresolved is not None :
480+ rtc , was_resolved , resolver_error = preresolved [idx ]
481+ else :
482+ # Lazy: resolve only when this call's turn comes, after every
483+ # earlier call in the batch has fully executed.
484+ rtc , was_resolved , resolver_error = await _resolve_tool_call (
485+ tc , context , resolve_tool_call , signal
486+ )
502487
503- if isinstance (preparation , _ImmediateOutcome ):
504- finalized = _FinalizedOutcome (
505- tool_call = rtc ,
506- result = preparation .result ,
507- is_error = preparation .is_error ,
508- blocked_by_hook = preparation .blocked_by_hook ,
509- block_reason = preparation .block_reason ,
510- hitl_trace = preparation .hitl_trace ,
488+ await emit_event (
489+ emit_fn ,
490+ ToolExecutionStartEvent (
491+ tool_call_id = rtc .id , tool_name = rtc .name , args = rtc .arguments
492+ ),
511493 )
512- else :
513- result , is_error = await _execute_prepared (preparation , signal , emit_fn )
514- finalized = await _finalize (
494+
495+ preparation = resolver_error or await _prepare_tool_call (
515496 context ,
516497 assistant_message ,
517- preparation ,
518- result ,
519- is_error ,
520- after_tool_call ,
498+ rtc ,
499+ before_tool_call ,
521500 signal ,
501+ resolved = was_resolved ,
522502 )
523503
524- await emit_event (
525- emit_fn ,
526- ToolExecutionEndEvent (
527- tool_call_id = rtc .id ,
528- tool_name = rtc .name ,
529- result = finalized .result ,
530- is_error = finalized .is_error ,
531- terminate = bool (finalized .result .terminate ),
532- blocked_by_hook = finalized .blocked_by_hook ,
533- block_reason = finalized .block_reason ,
534- ),
535- )
536- tool_msg = _make_tool_result_message (finalized )
537- await emit_event (emit_fn , MessageStartEvent (message = tool_msg ))
538- await emit_event (emit_fn , MessageEndEvent (message = tool_msg ))
539- finalized_list .append (finalized )
540- messages .append (tool_msg )
504+ if isinstance (preparation , _ImmediateOutcome ):
505+ finalized = _FinalizedOutcome (
506+ tool_call = rtc ,
507+ result = preparation .result ,
508+ is_error = preparation .is_error ,
509+ blocked_by_hook = preparation .blocked_by_hook ,
510+ block_reason = preparation .block_reason ,
511+ hitl_trace = preparation .hitl_trace ,
512+ )
513+ else :
514+ result , is_error = await _execute_prepared (preparation , signal , emit_fn )
515+ finalized = await _finalize (
516+ context ,
517+ assistant_message ,
518+ preparation ,
519+ result ,
520+ is_error ,
521+ after_tool_call ,
522+ signal ,
523+ )
524+
525+ await emit_event (
526+ emit_fn ,
527+ ToolExecutionEndEvent (
528+ tool_call_id = rtc .id ,
529+ tool_name = rtc .name ,
530+ result = finalized .result ,
531+ is_error = finalized .is_error ,
532+ terminate = bool (finalized .result .terminate ),
533+ blocked_by_hook = finalized .blocked_by_hook ,
534+ block_reason = finalized .block_reason ,
535+ ),
536+ )
537+ tool_msg = _make_tool_result_message (finalized )
538+ await emit_event (emit_fn , MessageStartEvent (message = tool_msg ))
539+ await emit_event (emit_fn , MessageEndEvent (message = tool_msg ))
540+ finalized_list .append (finalized )
541+ messages .append (tool_msg )
542+
543+ except HitlControlException as exc :
544+ # Completed calls' results were already emitted (and
545+ # checkpointed) per-iteration; carry them on the exception so
546+ # the stateless loop entry points can return them to callers
547+ # that persist the loop's return value across the suspend.
548+ exc .partial_tool_results = tuple (messages )
549+ raise
541550
542551 return ToolCallBatch (messages = messages , terminate = _should_terminate (finalized_list ))
543552
@@ -687,6 +696,7 @@ async def _run(prep: _PreparedToolCall) -> _FinalizedOutcome:
687696
688697 finalized_list : list [_FinalizedOutcome ] = []
689698 control_exc : BaseException | None = None
699+ framework_exc : BaseException | None = None
690700 for entry , slot in zip (entries , scheduled ):
691701 if isinstance (slot , _FinalizedOutcome ):
692702 finalized_list .append (slot )
@@ -710,18 +720,23 @@ async def _run(prep: _PreparedToolCall) -> _FinalizedOutcome:
710720 if control_exc is None :
711721 control_exc = exc
712722 continue
723+ if not isinstance (exc , asyncio .CancelledError ):
724+ # Tool and hook failures are converted to error results at the
725+ # source (_execute_prepared/_finalize), so anything else landing
726+ # here is framework-side — typically emit_fn raising while
727+ # processing a Start/End event. Emitter failures propagate at
728+ # every other emit_event call site; synthesizing a bogus
729+ # tool_result here would let the run continue with event
730+ # processing broken. Deferred until every task has settled.
731+ if framework_exc is None :
732+ framework_exc = exc
733+ continue
713734 # Per-task isolation: a stray CancelledError (tool self-cancel with
714- # no outer cancel — a tool bug) or any exception that slipped past
715- # _execute_prepared/_finalize degrades to an error result for THIS
716- # call only.
717- text = (
718- "[Tool execution cancelled]"
719- if isinstance (exc , asyncio .CancelledError )
720- else str (exc )
721- )
735+ # no outer cancel — a tool bug) degrades to an error result for
736+ # THIS call only.
722737 synthesized = _FinalizedOutcome (
723738 tool_call = entry .tool_call ,
724- result = _error_result (text ),
739+ result = _error_result ("[Tool execution cancelled]" ),
725740 is_error = True ,
726741 hitl_trace = entry .hitl_trace ,
727742 )
@@ -739,17 +754,27 @@ async def _run(prep: _PreparedToolCall) -> _FinalizedOutcome:
739754 )
740755 finalized_list .append (synthesized )
741756
757+ if framework_exc is not None :
758+ # Event processing is broken, so emitting sibling results below
759+ # would fail too. Every task has already settled (no leaks) —
760+ # propagate, taking precedence over a suspend: durably suspending
761+ # a run whose event pipeline is failing is not safe.
762+ raise framework_exc
763+
742764 if control_exc is not None :
743- # Persist the siblings (each MessageEndEvent checkpoints
744- # immediately), best-effort, then let the control exception
745- # propagate exactly as the suspend/abort machinery expects.
746- try :
747- await _emit_tool_result_messages (finalized_list , emit_fn )
748- except Exception :
749- # Best-effort: sibling persistence must never swallow the
750- # control exception — the suspend/abort machinery depends on
751- # it propagating; unanswered ids are backfilled on resume.
752- pass
765+ # Persist the siblings BEFORE propagating the suspend (each
766+ # MessageEndEvent checkpoints immediately). Deliberately NOT
767+ # best-effort: suspending after a sibling's persistence failed
768+ # would durably record a batch whose completed work is missing —
769+ # resume would re-run tools whose side effects already happened.
770+ # A persistence failure propagates instead (consistent with every
771+ # other MessageEndEvent site): the run fails rather than suspends.
772+ emitted = await _emit_tool_result_messages (finalized_list , emit_fn )
773+ if isinstance (control_exc , HitlControlException ):
774+ # Stateless loop entry points append these to the message
775+ # lists they return, so callers persisting the return value
776+ # keep the completed siblings' results across the suspend.
777+ control_exc .partial_tool_results = tuple (emitted )
753778 raise control_exc
754779
755780 messages = await _emit_tool_result_messages (finalized_list , emit_fn )
0 commit comments