Skip to content

NextStepInterruption bypasses resumed Session-write recovery and loses tool guardrail results #4646

Description

@FU-max-boop

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:

  1. The first approved tool executes.
  2. Its input/output tool guardrails and start/end hooks complete.
  3. Another approval remains, so the advanced state is NextStepInterruption.
  4. Persisting the completed tool output to a client-managed Session raises.
  5. 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:

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:

  1. Atomic no-commit: the batch is absent when the call raises.
  2. 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:

  • P be the exact Session history before the resumed append;
  • B be the canonical resumed output batch;
  • G be the completed tool input/output guardrail results; and
  • I be 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.

  1. After the tool, guardrails, and hooks complete, but before awaiting Session.add_items(), record a detached publication envelope in RunState.
  2. Preserve it across exceptions, cancellation, to_json(), and from_json().
  3. While it is pending, do not allow another approval, tool, hook, guardrail, sandbox action, or model request to take effect.
  4. On a recovery-only resume with the original Session ID/backend and exclusive history access:
    • if history is exactly P, append a detached copy of B;
    • if history is exactly P + B, acknowledge the lost commit without appending;
    • otherwise retain the envelope and fail closed.
  5. Publish G into the recovered RunState and public interruption result exactly once.
  6. Verify that the restored continuation still matches I.
  7. Clear the envelope only after both Session reconciliation and guardrail-result publication complete.
  8. 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:

  1. Record a durable non-resumable marker before attempting the append.
  2. Preserve completed guardrail results in the failed checkpoint or public recovery payload.
  3. After a raised or cancelled append, reject approve(), reject(), pending-input mutation, and every Runner resume before any later action.
  4. 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.
  5. 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:

  1. The approved tool, its input/output guardrails, and its start/end hooks execute exactly once.
  2. No remaining approval can cause a later tool or model action while publication is unsettled.
  3. Successful recovery leaves exactly one canonical call/output pair in the Session.
  4. Completed tool guardrail results survive live and JSON recovery and appear exactly once in later public results.
  5. The exact remaining interruption identities and decisions are preserved; the completed approval is never resurrected.
  6. The original Session exception still propagates from the failing attempt.
  7. Session-ID mismatch, changed history, ambiguous history, partial writes, and concurrent resume attempts fail closed without clearing the envelope.
  8. 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:

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions