Repository navigation
Description
Describe the bug
#4630 fixed failed resumed Session appends when resolving an approval advances the state to NextStepRunAgain. This report deliberately does not reopen that path.
A separate transition remains when one model response contains multiple approval-gated local tools and the caller resolves only a subset:
- The first approved tool executes.
- Its input/output tool guardrails and start/end hooks complete.
- Another approval remains, so the advanced state is
NextStepInterruption. - Persisting the completed tool output to a client-managed
Sessionraises. - The state remains serializable and the second approval remains actionable, but the first output is not reconciled and its completed tool guardrail results are absent from durable state and every later public result.
This reproduces on both v0.22.0 and current main at 36976b1854534c0f184ed955afd542493abbb5d9. It uses ScriptedModel and makes no provider or network call.
Debug information
- Agents SDK version: v0.22.0 (
4df9ecfae1761ca6fea67cc5a20b383c1d492024) and current main (36976b1854534c0f184ed955afd542493abbb5d9) - Related library versions: none
- Python version: 3.12.13
- Operating system: macOS 26.5 arm64
- Model and model provider:
ScriptedModel; no provider - Does the issue reproduce with the latest Agents SDK release? Yes
- Does the issue occur consistently or intermittently? Consistently; 4/4 branch-cover rows reproduce on both refs
The injected error is: RuntimeError: injected ambiguous renewed-interruption write failure
Why #4630 does not cover this transition
The current pending-write implementation is gated to NextStepRunAgain:
save_resumed_turn_items()only suppliesresumed_write_stateforNextStepRunAgain.RunStatedeserialization rejects a pending Session write unless the restored step isNextStepRunAgain.- In the non-streaming path, the state advances before the resumed write, while tool guardrail results are published after it.
- The streaming path has the corresponding boundary in
run_loop.py.
For NextStepInterruption, the append therefore has no durable recovery marker. The durable RunState also does not retain the completed tool input/output guardrail results when the append raises.
Deterministic scenario and observations
The model returns two approval-gated function calls in one response:
charge(x=7)
notify()
The caller approves only charge. It has one input guardrail, one output guardrail, start/end hooks, and one observable local side effect. Session.add_items() for its resumed output then follows one of two indistinguishable failure outcomes:
- Atomic no-commit: the batch is absent when the call raises.
- Commit-then-raise: the batch is durable, but acknowledgement is lost.
The matrix covers failing run/run_streamed, live/JSON state, and both Session outcomes:
| ID | Failing resume | Recovery resume | State transport | Session outcome |
|---|---|---|---|---|
RR-live-absent |
run |
run |
live | atomic no-commit |
RS-json-commit |
run |
run_streamed |
JSON | commit-then-raise |
SR-live-commit |
run_streamed |
run |
live | commit-then-raise |
SS-json-absent |
run_streamed |
run_streamed |
JSON | atomic no-commit |
Every row observes:
charge effect / input guardrail / output guardrail / start hook / end hook = 1 / 1 / 1 / 1 / 1
current step after failure = next_step_interruption
remaining interruption = notify
model calls before resolving notify = 1
durable RunState tool guardrail results = 0 / 0
pending and final public tool guardrail results = 0 / 0
For atomic no-commit, the Session contains charge call/output counts 1 / 0. For commit-then-raise, it contains 1 / 1. Retrying currently continues from the remaining notify interruption without settling this distinction. The atomic-no-commit rows end with durable Session history missing the charge output while public replay contains it.
The same deterministic harness passes these bug assertions unchanged on both refs above. Switching the injected Session between the two outcomes requires only moving items.extend(batch) immediately before the raised exception.
Failure model
Let:
Pbe the exact Session history before the resumed append;Bbe the canonical resumed output batch;Gbe the completed tool input/output guardrail results; andIbe the exact remaining interruption set.
After an ambiguous append failure, the durable Session is either P or P + B, while the advanced state already contains I. Blindly appending B can duplicate a committed batch. Blindly skipping it preserves a missing output. Allowing I to proceed before resolving the ambiguity admits later actions on top of an uncommitted post-side-effect boundary.
The logical recovery unit is therefore a post-side-effect publication envelope binding:
exact Session batch and pre-write frontier
+ target persisted-item count
+ completed tool input/output guardrail results
+ NextStepInterruption continuation and remaining approval identities
Two coherent contracts
Option A: recoverable publication envelope
This is my preferred direction because it extends the reconciliation semantics already introduced by #4630.
- After the tool, guardrails, and hooks complete, but before awaiting
Session.add_items(), record a detached publication envelope inRunState. - Preserve it across exceptions, cancellation,
to_json(), andfrom_json(). - While it is pending, do not allow another approval, tool, hook, guardrail, sandbox action, or model request to take effect.
- On a recovery-only resume with the original Session ID/backend and exclusive history access:
- if history is exactly
P, append a detached copy ofB; - if history is exactly
P + B, acknowledge the lost commit without appending; - otherwise retain the envelope and fail closed.
- if history is exactly
- Publish
Ginto the recoveredRunStateand public interruption result exactly once. - Verify that the restored continuation still matches
I. - Clear the envelope only after both Session reconciliation and guardrail-result publication complete.
- Return the still-pending interruption without rerunning the completed tool, its guardrails, or its hooks.
Option B: durable fail-closed state
If interruption-aware recovery is intentionally outside the SDK contract:
- Record a durable non-resumable marker before attempting the append.
- Preserve completed guardrail results in the failed checkpoint or public recovery payload.
- After a raised or cancelled append, reject
approve(),reject(), pending-input mutation, and every Runner resume before any later action. - Explain that the local side effect completed while Session acknowledgement is ambiguous, and that the application must repair the original Session or start from a new safe run boundary.
- Never roll the state back to the original approval or rerun the completed tool.
What is unsafe is the current third behavior: retaining an actionable NextStepInterruption while neither reconciling nor durably poisoning the completed publication boundary.
Minimum invariants
Whichever contract is chosen:
- The approved tool, its input/output guardrails, and its start/end hooks execute exactly once.
- No remaining approval can cause a later tool or model action while publication is unsettled.
- Successful recovery leaves exactly one canonical call/output pair in the Session.
- Completed tool guardrail results survive live and JSON recovery and appear exactly once in later public results.
- The exact remaining interruption identities and decisions are preserved; the completed approval is never resurrected.
- The original Session exception still propagates from the failing attempt.
- Session-ID mismatch, changed history, ambiguous history, partial writes, and concurrent resume attempts fail closed without clearing the envelope.
- Streaming and non-streaming paths expose equivalent durable state and public results.
Expected behavior
The SDK should choose either recoverable reconciliation or a durable fail-closed contract before permitting the remaining interruption to advance. It should not allow later actions while the completed tool's Session publication is ambiguous, and completed tool guardrail results should not disappear from durable/public state.
Compatibility and scope
v0.22.0 writes RunState schema 1.16. The pending Session-write shape added by #4630 is schema 1.17 on current main and has not shipped in a release, so an interruption-aware form can still be reshaped before release while preserving released 1.16 reads, if maintainers choose Option A.
Explicit non-goals:
NextStepFinalOutputor terminal recovery;NextStepHandoff;- compaction intent, compact requests, or replacement publication;
- the existing
NextStepRunAgainpath fixed by fix(sessions): recover failed resumed Session writes before model calls #4630; - server-managed conversations;
- distributed exactly-once execution, concurrent writers, backend identity proof, CAS, or recovery from non-atomic partial writes;
- replaying or rolling back an irreversible tool;
- approval argument override semantics from feat: add approval override argument parity for RunState resumes #2695; and
- run-level terminal output guardrails.
I searched open issues and pull requests for NextStepInterruption, pending Session writes, resumed Session persistence, interruption recovery, and tool guardrail-result publication. I did not find an open functional duplicate. I can prepare provider-neutral regression tests and a narrowly scoped implementation after maintainers choose recovery versus durable fail-closed semantics.
Activity
FU-max-boop commentedon Aug 25, 2026
Executable provider-neutral four-row reproducer: https://gist.github.com/FU-max-boop/1eadecf110e7505ddaf9c67eb74f6053
SHA-256:
82032e1c611d36952954ed4bd04692f861cc122dd3a316fe1bc4741c6f70135eRun it with the target checkout's
srcfirst onPYTHONPATH. It usesagents.testing.ScriptedModel, injects both atomic-no-commit and commit-then-raise Session outcomes, and makes no API or provider call. I reran this exact file against v0.22.0 andmain@36976b1854534c0f184ed955afd542493abbb5d9; all four bug assertions reproduced on both.