Skip to content

Commit ed26371

Browse files
claudexfgong
authored andcommitted
fix(agent): address codex review — carry salvaged results, strict suspend persistence, propagate emitter failures
- HitlControlException gains partial_tool_results; both executors attach the already-persisted sibling ToolResultMessages, and the stateless loop HITL handlers append them to the returned message lists, so callers persisting run_agent_loop*'s return value keep completed siblings' results across a suspend. - Sibling persistence before a suspend is no longer best-effort: an emit/checkpoint failure propagates (run fails rather than durably suspending with completed work missing). The cancel salvage path stays best-effort — CancelledError must reach the Agent backfill. - Non-control exceptions reaching batch classification are framework failures (emit_fn raising in a worker), not tool failures: re-raised after all tasks settle instead of being masked as synthetic tool_results.
1 parent d330802 commit ed26371

6 files changed

Lines changed: 277 additions & 97 deletions

File tree

cubepi/agent/loop.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,17 +203,25 @@ async def run_agent_loop_resume(
203203
new_messages=new_messages,
204204
set_outcome=set_outcome,
205205
)
206-
except HitlDetached: # pragma: no cover — E2E tested
206+
except HitlDetached as exc: # pragma: no cover — E2E tested
207207
# The Agent caller (Agent.detach) emitted AgentSuspendedEvent already.
208208
# Loop exits silently — assistant message and pending state remain
209-
# intact for the next respond() call.
209+
# intact for the next respond() call. Sibling tool_results that
210+
# completed before the detach were emitted by the executor; append
211+
# them so callers persisting the returned messages keep them.
212+
for msg in exc.partial_tool_results:
213+
context.messages.append(msg)
214+
new_messages.append(msg)
210215
if set_outcome is not None:
211216
set_outcome("suspended")
212217
return new_messages
213-
except HitlAborted: # pragma: no cover — E2E tested
218+
except HitlAborted as exc: # pragma: no cover — E2E tested
214219
# The Agent caller (Agent.abort_pending) emitted AgentAbortedEvent
215220
# already. Loop exits silently — synthetic deny + terminal aborted
216221
# assistant message already appended by abort_pending.
222+
for msg in exc.partial_tool_results:
223+
context.messages.append(msg)
224+
new_messages.append(msg)
217225
if set_outcome is not None:
218226
set_outcome("abandoned")
219227
return new_messages
@@ -442,17 +450,26 @@ async def _run_loop(
442450
emit=emit,
443451
set_outcome=set_outcome,
444452
)
445-
except HitlDetached:
453+
except HitlDetached as exc:
446454
# AgentSuspendedEvent already emitted by Agent.detach() — exit silently
447455
# so AgentEndEvent doesn't double-signal termination. Assistant message
448456
# and pending state remain intact for the next respond() call.
457+
# Sibling tool_results that completed before the detach were emitted
458+
# (and checkpointed) by the executor; append them so callers reading
459+
# current_context/new_messages keep them too.
460+
for msg in exc.partial_tool_results:
461+
current_context.messages.append(msg)
462+
new_messages.append(msg)
449463
if set_outcome is not None:
450464
set_outcome("suspended")
451465
return
452-
except HitlAborted:
466+
except HitlAborted as exc:
453467
# AgentAbortedEvent already emitted by Agent.abort_pending() — exit
454468
# silently. Synthetic deny + terminal aborted assistant message are
455469
# already appended; conversation is closed.
470+
for msg in exc.partial_tool_results:
471+
current_context.messages.append(msg)
472+
new_messages.append(msg)
456473
if set_outcome is not None:
457474
set_outcome("abandoned")
458475
return

cubepi/agent/tools.py

Lines changed: 101 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

cubepi/hitl/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
from __future__ import annotations
22

3+
from typing import TYPE_CHECKING, Sequence
4+
5+
if TYPE_CHECKING: # pragma: no cover
6+
from cubepi.providers.base import ToolResultMessage
7+
38

49
class HitlControlException(BaseException):
510
"""Base for HITL control-flow exceptions.
611
712
Inherits BaseException so existing `except Exception:` handlers in
813
cubepi.agent.tools._prepare_tool_call and _execute_prepared do NOT
914
swallow these — mirrors asyncio.CancelledError.
15+
16+
``partial_tool_results``: ToolResultMessages of sibling tool calls in
17+
the same batch that completed (and were emitted/checkpointed) before
18+
this control exception suspended the run. The tool executors set it so
19+
the stateless loop entry points can append those results to the
20+
message lists they return — a caller persisting the loop's return
21+
value must not lose a completed sibling's result just because another
22+
call in the batch detached.
1023
"""
1124

25+
partial_tool_results: Sequence[ToolResultMessage] = ()
26+
1227

1328
class HitlCancelled(HitlControlException):
1429
def __init__(self, reason: str):

dev/specs/2026-07-05-tool-batch-fault-isolation.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,34 @@ the bare `await` loop. Every task settles before any outcome is processed;
121121
results stay in tool_call order. Then classify each slot:
122122

123123
1. **`_FinalizedOutcome`** — success path, unchanged.
124-
2. **Ordinary failure** (any exception that is not `HitlControlException`,
125-
`CancelledError`, `KeyboardInterrupt`, or `SystemExit`) — synthesize an
126-
error outcome for that tool_call id:
127-
`AgentToolResult(content=[TextContent(text=str(exc))], is_error=True)`,
128-
plus a paired `ToolExecutionEndEvent` (the Start was emitted inside
129-
`_run`; an unpaired Start would leave `state.pending_tool_calls` and trace
130-
spans open — same invariant the prepare-phase comment protects).
131-
With Part 2 in place this is defense-in-depth: tool-body and hook failures
132-
are already converted inside the task.
124+
2. **Framework failure** (any exception that is not `HitlControlException`,
125+
`CancelledError`, `KeyboardInterrupt`, or `SystemExit`) — with Part 2 in
126+
place, tool-body and hook failures are converted to error results *at the
127+
source*, inside the task. So an exception landing in the classification
128+
stage is framework-side — in practice `emit_fn` raising while processing
129+
a Start/End event. Emitter failures propagate at every other
130+
`emit_event` call site, and synthesizing a bogus `tool_result` for one
131+
would let the run continue with event processing broken; so it is
132+
recorded and **re-raised after every task has settled** (no leaks, no
133+
conversion). A framework failure takes precedence over a pending
134+
suspend: durably suspending a run whose event pipeline is failing is not
135+
safe.
133136
3. **`HitlControlException`** — the suspend contract requires propagation, so
134137
it cannot become an error result. Order of operations: first emit the
135138
`ToolResultMessage`s for every *other* settled slot (each `MessageEndEvent`
136139
checkpoints immediately via `Agent._process_event`, `agent.py:1207-1209`),
137-
then re-raise the HITL exception. The HITL call's own tool_call
140+
then re-raise the HITL exception. This emission is deliberately **not**
141+
best-effort: if a sibling's persistence fails, suspending anyway would
142+
durably record a batch whose completed work is missing (resume would
143+
re-run side-effecting tools), so the persistence failure propagates and
144+
the run fails rather than suspends. The emitted sibling results are also
145+
attached to the exception as
146+
`HitlControlException.partial_tool_results`; the stateless loop entry
147+
points (`_run_loop` / `run_agent_loop_resume` HITL handlers) append them
148+
to the message lists they return, so callers persisting the loop's
149+
return value — who never see the raised-through batch — keep the
150+
completed siblings' results. (The sequential executor attaches the same
151+
attribute for its already-emitted prefix.) The HITL call's own tool_call
138152
deliberately stays unanswered and gets no End event — identical to today's
139153
sequential detach shape — and the existing detach/resume/abort paths
140154
answer it (synthetic-deny backfill scans unanswered ids of the last

0 commit comments

Comments
 (0)