Skip to content

fix(sessions): settle an accepted terminal output after a failed resumed append - #4698

Open
Ayushraj06-bit wants to merge 3 commits into
openai:mainfrom
Ayushraj06-bit:fix/terminal-output-session-append-recovery
Open

fix(sessions): settle an accepted terminal output after a failed resumed append#4698
Ayushraj06-bit wants to merge 3 commits into
openai:mainfrom
Ayushraj06-bit:fix/terminal-output-session-append-recovery

Conversation

@Ayushraj06-bit

@Ayushraj06-bit Ayushraj06-bit commented Aug 26, 2026

Copy link
Copy Markdown

Summary

When a resumed, approval-gated run ends via tool_use_behavior="stop_on_first_tool", the tool output becomes the terminal agent output. If the final client-managed Session.add_items() append fails, the exception propagates correctly but the resulting RunState can neither recover nor explicitly reject that accepted terminal result.

Cause

The terminal batch is appended after the output guardrails so a tripwire can still redact it, which makes it the last fallible step of the run.

  • save_final_turn_items_after_guardrails() never passed a resumed_write_state, so the pending_session_write checkpoint introduced in fix(sessions): recover failed resumed Session writes before model calls #4630 was not armed for that batch.
  • The exception escapes before run_state._current_step = None, so the live step stays NextStepFinalOutput. That type is outside the NextStepInterruption | NextStepRunAgain | None union RunState declares and serializes, so the snapshot records current_step = null.
  • The passing output guardrail results are never published to RunState, so the failed state keeps no evidence that the accepted output already cleared them.

These are one defect rather than two: from_json() already rejects a restored pending_session_write whose current_step is not a resumable step, so arming the checkpoint without also making the terminal step durable would fail every JSON round trip.

Retrying the same live or JSON-restored state therefore re-enters the model, evaluates the guardrail against a new final output, repeats the agent lifecycle hooks, and returns a different result, while a fail-before-commit Session permanently lacks the tool call/output pair.

Fix

Treat an accepted terminal output as a durable checkpoint, and settle it on the next resume before any further work. The existing pending-write mechanism carries the exact function-tool batch; no new persistence mechanism, no new step type, and no RunState schema bump.

The checkpoint replays one Session append, so it may only be armed when that append really is all the run has left. Three helpers keep that rule in one place:

  • terminal_checkpoint_owner() decides whether the resumed state may own the deferred append. Callers opt in through settle_terminal_output, so only the finalize path that saw every output guardrail succeed can arm it.
  • resumed_write_owner() resolves the owning state for a resumed write generally: a run-again or interruption step always owns its write, a terminal step only under the stricter rule above.
  • recoverable_terminal_step() decides whether a resumed state may be settled or serialized, and additionally requires an outstanding _pending_session_write. Reconciling clears that checkpoint, so both runners capture this before calling resume_pending_session_write().

Settling happens at the top of the run loop in both runners, ahead of the first-turn input guardrails and sandbox_runtime.prepare_agent(), so an already accepted output never creates or tears down a provider sandbox and a sandbox startup failure cannot withhold it.

Support boundary

A terminal output is only settled when the checkpoint represents everything still owed for the batch:

  • All output guardrails completed. The tripwire, guardrail-error, and max-turns saves reach the same persistence helper and arm nothing, so an error there surfaces on the next resume instead of being settled away.
  • The output is a plain string. Richer values fall back to json.dumps(..., default=str) and then str(value), which is a useful diagnostic codec but not an identity-preserving one, so structured and custom outputs keep the existing non-resumable behavior. Checking the accepted value subsumes checking Agent.output_type, because the runtime already normalizes a None or str output type to str before the step is built.
  • The Session owes no post-append maintenance. A compaction-aware Session also records a deferred compaction for the batch, which the checkpoint does not carry, so those runs keep the existing non-resumable behavior rather than settling a batch whose maintenance the replay would skip.

Live and JSON-restored states apply the same rule, so both have one contract, and a snapshot never advertises a checkpoint it cannot honor.

Behavior

  • The first Session exception still propagates unchanged.
  • The approved tool side effect, its guardrails, and its hooks each run exactly once.
  • A settled retry returns the original output with no second model call and no repeated on_agent_start / on_agent_end.
  • Both the atomic-failure and the commit-then-raise (lost acknowledgement) outcomes reconcile without duplicating the pair.
  • Session, RunResult.to_input_list(), the restored RunState, and the next model input each contain exactly one ordered call/output pair.
  • A missing, different, or concurrently changed Session still fails closed through the existing resume_pending_session_write() checks.
  • RunState.add_input() continues to reject a settled terminal state.

Alternatives considered

  • Arm the checkpoint only. Not viable on its own: from_json() rejects a pending write whose current_step is not resumable, so JSON resume would fail outright, and the retry would still re-enter the model.
  • Normalize the terminal step to NextStepRunAgain, as the resumed handoff boundary does in fix(sessions): checkpoint a resumed handoff before the Session append #4689. Ruled out in the issue, and wrong here: under stop_on_first_tool the tool output is the answer, so replaying the model both changes an already accepted result and feeds that output back as model input.
  • Persist arbitrary NextStepFinalOutput.output: Any. The generic codec cannot preserve type or value, so this would turn a narrow Session repair into an arbitrary-object serialization promise.
  • Carry response_id in the checkpoint and replay _defer_compaction() on resume, instead of declining to arm for compaction-aware Sessions. That widens the checkpoint payload, and the same gap already exists for the run-again and interruption recovery paths from fix(sessions): recover failed resumed Session writes before model calls #4630, so it seemed better raised separately than folded in here. Happy to do it in this PR if maintainers prefer the broader change.
  • An explicit terminal-unrecoverable marker for structured outputs (contract 2 in the issue). Left out deliberately: it would turn a currently-succeeding retry into a hard error without a reported need for it.

Notes for review

  • The issue asks which of two contracts maintainers prefer. This implements recoverable terminal continuation, bounded as above, and falls back to the existing non-resumable behavior wherever recovery is not provable. Happy to redirect if the other contract is preferred.
  • Schema 1.17 is unreleased (the published 0.22.0 wheel ships 1.16), so per .agents/references/runstate-schema.md this extends the existing 1.17 summary instead of bumping to 1.18. A restore still fails closed for a next_step_final_output step carried on an older schema label. Happy to bump instead.
  • The streamed recovery emits no new item stream events: RunResultStreaming is already seeded from the state, and the failed attempt already streamed those items. Deliberate, and easy to change.
  • No docs/ change here. No published guidance describes this failure boundary, and per the documentation release timing rule any write-up belongs to separately timed docs-only work.

Test plan

test_resumed_terminal_output_is_settled_before_next_model covers the full sync/stream, same-mode/cross-mode, live/JSON, fail-before-commit/commit-then-raise matrix (16 rows). Alongside it: agent lifecycle hook non-replay across modes, ambiguous-recovery fail-closed (missing, different, and changed Session), structured-output non-resumability, invalid serialized payloads (non-string output and older schema label), added-input rejection, sandbox preparation being skipped for a settled output, a guardrail error not being settled away, and a compaction-aware Session declining to arm a checkpoint.

  • New tests on main at a40ae98: 28 failed, 60 passed. With this change: 88 passed.
  • make tests: 7670 passed / 147 skipped parallel, 77 passed / 4 skipped serial. The remaining failures and collection errors in this local environment are pre-existing on unpatched main (Windows symlink, tar, and mount sandbox tests, plus uninstalled optional extras), verified by running the identical suite against unpatched source and diffing the results.
  • make format, make lint, and make mypy are clean on all touched files.
  • make pyright was not run locally (Node is unavailable in this environment), so it is worth confirming in CI.

Issue number

Fixes #4690

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

…med append

A resumed turn that ends via tool_use_behavior appends its terminal batch
after the output guardrails, but never armed the pending_session_write
checkpoint for it, and NextStepFinalOutput was outside the steps RunState
can own or serialize. An append failure therefore left a state that could
neither recover nor reject the accepted result: retrying re-entered the
model, re-ran the guardrail, repeated the agent lifecycle hooks, and
returned a different final output while the Session could permanently lack
the tool call/output pair.

Let the resumed state own that batch when the accepted output is a plain
string, publish the passing output guardrail results before the append can
raise, and settle the same output on the next resume before any model call
or hook. Richer outputs cannot round-trip through the RunState codec
unchanged, so they keep the existing non-resumable behavior.

Fixes openai#4690

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d2958d9ec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run.py Outdated
Comment thread src/agents/run_internal/session_persistence.py Outdated
…hat is owed

Two review findings shared one cause: the terminal step was treated as
settleable on its own, and the check ran too late in the resume.

An accepted terminal output is durable only while its append checkpoint is
still outstanding. Once resume_pending_session_write() reconciles that batch,
later persistence work for it, such as compaction, can still fail, and the
cleared checkpoint made the leftover step look settled. A retry then reported
a completed run for an output whose required Session maintenance had failed.
Split the predicate so arming keeps using the step alone, while settling and
serialization also require an outstanding pending write, and capture it before
reconciling clears it.

The settle also ran after sandbox preparation, so retrying an already accepted
output needlessly created and cleaned up a provider sandbox, and a sandbox
startup failure could withhold an output that needed no further model or tool
work. Move it to the top of the run loop in both runners, ahead of input
guardrails and sandbox preparation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9b541f434

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run_internal/agent_runner_helpers.py Outdated
@Ayushraj06-bit

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9b541f434

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run.py
…alize path

Three review findings landed on the same rule, so this replaces it rather than
subtracting another case from it. The checkpoint replays one Session append, so
it may only be armed when that append really is all the run has left.

Ownership is now decided by terminal_checkpoint_owner() and opted into by the
caller, instead of being inferred from the step:

- Only the finalize path that saw every output guardrail succeed passes
  settle_terminal_output. The tripwire, guardrail-error, and max-turns saves
  reach the same helper and must not arm a checkpoint, because an error there
  has to surface on the next resume rather than be settled away.
- A compaction-aware Session also owes a deferred compaction for the batch,
  which the checkpoint does not carry. Those runs keep the existing
  non-resumable behavior instead of settling a batch whose maintenance the
  replay would skip.

resumed_write_owner() now resolves the owning state at the call sites, so
save_resumed_turn_items() forwards an already resolved owner and its signature
is unchanged.
@Ayushraj06-bit

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 06ff1f86cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Ayushraj06-bit

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 06ff1f86cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plain-string terminal recovery addresses the demonstrated failure, including the repeated agent lifecycle hooks. The remaining support boundary is not safe yet.

Structured/custom terminal outputs and compaction-aware Sessions are supported public paths, but the new tests explicitly leave them with the old non-resumable behavior. Retrying those states can still enter the model and run lifecycle hooks again after the original tool effect and terminal-output hooks already completed.

Please use one terminal checkpoint boundary for every post-acceptance Session append failure. A losslessly persisted string can settle the original output. If the output cannot round-trip losslessly, or post-append maintenance cannot be replayed safely, persist an explicit terminal-unrecoverable state and reject resume before sandbox preparation, agent/model/tool/guardrail work, or lifecycle hooks. This should be a fail-closed branch, not another recovery mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resumed terminal tool output cannot recover after a Session append failure

2 participants