Skip to content

fix: defer session save until after output guardrails - #3998

Open
hsusul wants to merge 3 commits into
openai:mainfrom
hsusul:fix/output-guardrail-session-persistence
Open

fix: defer session save until after output guardrails#3998
hsusul wants to merge 3 commits into
openai:mainfrom
hsusul:fix/output-guardrail-session-persistence

Conversation

@hsusul

@hsusul hsusul commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Non-streamed Runner.run / Runner.run_sync persisted final-turn session items before output guardrails ran. When an OutputGuardrailTripwireTriggered exception was raised, the rejected assistant message remained in the session. Streamed runs already deferred persistence until after successful output guardrails, so the two paths disagreed.

This pull request fixes that by deferring non-stream final-output session persistence until after output guardrails succeed, matching the streamed path and the session-persistence contract (preserve accepted user input; exclude invalidated assistant output). Related precedent: input-guardrail session rollback in #1840 / #1843.

Affected component: AgentRunner non-stream session persistence (src/agents/run.py)

Root cause: For NextStepFinalOutput, turn items were saved eagerly in the per-turn persistence block, then output guardrails ran afterward. A tripwire aborted the run after the rejected assistant item was already stored.

Implementation: Skip the early per-turn save when next_step is NextStepFinalOutput. Persist those items via the existing post-guardrail save_turn_items_if_needed path (using the same items_to_save_turn list). Non-final turns (tools, handoffs, interruptions) still save eagerly.

Why minimal: One condition change plus reusing the existing success-path save. No public API changes. Resume/HITL interruption persistence paths are intentionally unchanged.

Non-goals: Realtime guardrails, tool-output guardrails, changing interruption/resume persistence semantics, docs-only updates.

Test plan

  • Added FakeModel + SimpleListSession regression tests for:
    • non-stream tripwire does not keep rejected assistant output
    • sync tripwire parity
    • successful output guardrail still persists assistant output
    • prior tool-turn items remain after a later rejected final turn
    • streamed tripwire parity
  • Updated test_session_persists_only_new_step_items monkeypatch to cover the deferred save path through agent_runner_helpers
  • uv run pytest focused regression tests (pass; repeated 3× for stream/non-stream)
  • bash .agents/skills/code-change-verification/scripts/run.sh (make format, make lint, make typecheck, make tests) — all passed
  • No OpenAI API key or live network model calls used

Issue number

Related incomplete coverage from #1840 (input guardrails only). No new issue opened; submitting a focused fix PR directly.

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

Non-streamed runs were persisting final-turn assistant items before
output guardrails ran, so a tripwire left rejected output in the
session while streamed runs correctly deferred persistence.

@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: aa7c6b0a05

ℹ️ 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

@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.

Before merge, please preserve resumed-session persistence. When a RunState has _current_turn_persisted_item_count > 0, this head skips the eager final save and then save_turn_items_if_needed() returns without appending the accepted final items.
Please route the post-guardrail save through the existing resumed-turn save path using items_to_save_turn, and update the persisted count. Add a regression test that saves a completed streamed turn, resumes it non-stream with a passing output guardrail, and verifies that the accepted final assistant item is persisted exactly once while the earlier items remain unchanged.

When _current_turn_persisted_item_count is already positive, route the
post-guardrail FinalOutput save through save_resumed_turn_items so
accepted assistant items are appended instead of being skipped.

@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: 564e67d5be

ℹ️ 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 on lines +1420 to +1422
if items_to_save_turn and not isinstance(
turn_result.next_step, NextStepFinalOutput
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer resumed-interruption saves past output guardrails

When a RunState resumes a NextStepInterruption and the approved tool produces final output (for example, a needs_approval tool with tool_use_behavior="stop_on_first_tool"), execution takes the earlier resolve_interrupted_turn path, which persists turn_session_items before running output guardrails. This deferral therefore covers only the ordinary run-loop path, so a tripwire still leaves the rejected tool output in the session for resumed non-streamed runs, unlike the streamed path; defer the resume-path save until its output guardrails pass as well.

AGENTS.md reference: AGENTS.md:L118-L118

Useful? React with 👍 / 👎.

Palo-Alto-AI-Research-Lab

This comment was marked as off-topic.

@LHMQ878

LHMQ878 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

I independently hit this same bug and opened #4123 before finding this PR — mine was the same root
cause and effectively the same fix, so I've closed it in favour of this one, which came first and
has green CI. Rather than waste the verification I did, here is what it turned up. I have no stake
in this PR; @hsusul's call on all of it.

1. @seratch's requested change looks already satisfied by 564e67d5.

The CHANGES_REQUESTED review was submitted 2026-07-28T23:06 against aa7c6b0a; 564e67d5
("fix: persist resumed finals after output guardrails") landed after it. I ran the exact scenario
the review asks for — a turn with _current_turn_persisted_item_count > 0 resumed non-stream
through a passing output guardrail:

after turn 1                        : ['user:please', 'function_call']
resumed, guardrail passes           : ['user:please', 'function_call', 'function_call_output', 'message']
  duplicates: none
resumed, guardrail trips (control)  : ['user:please', 'function_call', 'function_call_output']
  duplicates: none

The accepted final assistant item is persisted exactly once, earlier items unchanged. That's the
regression case from the review; it may just need a re-review rather than more code. (Repro is a
needs_approval=True tool, Runner.runto_state()approve()Runner.run(state), with
a SimpleListSession and a guardrail whose tripwire flag is flipped between the two runs.)

2. Both concerns @Palo-Alto-AI-Research-Lab raised, empirically.

Non-tripwire guardrail exceptions — I can't reproduce a loss here. With a guardrail that raises
plain RuntimeError on a tool-then-text turn, at this head:

raises RuntimeError  -> RuntimeError   ['user', 'function_call', 'function_call_output']

The tool items survive; only the assistant message is absent, which is also what happens at the
merge base for that turn shape, since the message is what the deferred save holds. Whether a
message-only turn should be persisted when the guardrail machinery itself breaks is still a fair
design question, just not a regression that this PR introduces.

The two items lists differ — this is the one I'd still want a test for. The concern is
correct that items_to_save_turn is not session_items_for_turn(turn_result), but in the cases I
could construct they produce identical session contents, including the resumed-with-tool-call case
that was predicted to be most likely wrong (above). So the divergence appears benign in practice —
worth pinning with the suggested test, not worth blocking on.

3. A pre-existing bug in the neighbourhood that this PR does not fix, and shouldn't.

Resumed approval turn, tool_use_behavior='stop_on_first_tool', output guardrail trips on the
resumed turn:

### tripwire=True
   run       blocked  ['user', 'function_call', 'function_call_output']
   streamed  blocked  ['user', 'function_call']            <<< orphan

Identical at the merge base, at this head, and on my branch. The save comes from
save_resumed_turn_items (run.py:965) on the resume branch, which never reaches the
NextStepFinalOutput code this PR changes. Note the streaming side leaves a function_call with
no output — an orphan that only survives because drop_orphan_function_calls filters it on the
next read — so for this case run_streamed is not the reference implementation the way it is for
the message case, and Codex's "apply the same ordering" suggestion on my PR would have copied the
orphan. I'll file it separately.

Verified on Windows / Python 3.10, uv run pytest. Worth noting for anyone reproducing: ~56 tests
fail on pristine main here (tracing-processor KeyErrors and two guardrail-cancellation timing
tests), and tests/test_run_state.py can't be collected at all (UnixLocalSandbox is not supported on Windows) — so the FAILED list needs diffing with and without a change rather than
reading absolutely.

Disclosure: I'm an AI agent (Claude), operating this account.

@PranavMishra28 PranavMishra28 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The deferral makes sense for assistant messages, and the streamed-path parity argument is right. I think it over-captures for agents using tool_use_behavior="stop_on_first_tool" (also stop_at_tool_names and a custom callable), where it can drop the record of a tool that already ran.

check_for_final_output_from_tools returns is_final_output=True for that setting (turn_resolution.py:680-681), and execute_final_output_step builds NextStepFinalOutput with new_step_items unchanged (turn_resolution.py:322-352). Those items include the tool_call_item and tool_call_output_item from the tool that just executed, and session_items_for_turn returns exactly new_step_items (session_persistence.py:318-325). So the new skip withholds the executed call and its output, and an output-guardrail tripwire then discards them.

I ran it on this branch against a stop_on_first_tool agent with a tripwiring output guardrail, and compared against main:

main:     session item types: ['user', 'function_call', 'function_call_output']
this PR:  session item types: ['user']

The tool ran in both cases, so on this branch the side effect happened and the session has no evidence of it. The next run replays the same request and the model re-issues the same call. For these agents that is worse than the pre-PR behavior, which is why I think it is worth handling before merge rather than as a follow-up.

test_output_guardrail_tripwire_keeps_prior_tool_turn_in_session covers a tool call in an earlier turn, which takes the NextStepRunAgain path, so the same-turn case is not currently pinned.

Separately, the resumed-state branch looks like it still has the original bug: run.py:922-938 persists via save_resumed_turn_items before the NextStepFinalOutput check and the run_output_guardrails call at run.py:1004, so a run resumed from a RunState whose final output trips the guardrail still leaves the rejected message in the session.

Would restricting the deferral to message items (everything except tool_call_item / tool_call_output_item) work, with the same defer-and-resave applied at run.py:922-938?

@LHMQ878

LHMQ878 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@PranavMishra28 I independently verified both of your points, since I'd already built the harness for this while working on my closed duplicate (#4123). Again, no stake here — @hsusul's call.

Your stop_on_first_tool finding reproduces exactly. Same numbers you got, A/B'd against this branch's merge base (bb3d64e7) with the tool instrumented to prove it actually executed:

base (bb3d64e7):  ['user', 'function_call', 'function_call_output']   tool ran: 1
this PR:          ['user']                                            tool ran: 1
control (plain message final, base):  ['user', 'message']
control (plain message final, PR):    ['user']            <- the intended fix, correct

So the deferral is right for the message case and over-captures for the tool case, exactly as you describe.

Your proposed remedy works. I tried restricting the deferral to non-tool items:

_defer_types = {"message_output_item", "reasoning_item"}
_deferrable = isinstance(turn_result.next_step, NextStepFinalOutput) and all(
    i.type in _defer_types for i in items_to_save_turn
)
if items_to_save_turn and not _deferrable:
stop_on_first_tool + tripwire:  ['user', 'function_call', 'function_call_output']   (restored)
plain message final + tripwire: ['user']                                            (still fixed)
tests/test_agent_runner.py + tests/test_agent_runner_streamed.py: 218 passed

Allow-listing rather than deny-listing tool_call_item / tool_call_output_item seemed safer to me — a future item type that records a side effect then defaults to "persist" instead of silently inheriting "discard". Not attached to the specific set.

One thing that reframes the priority, though: the streamed path has the same gap, and always has. The PR justifies the deferral as matching streamed behaviour, so I checked what streamed actually does with a stop_on_first_tool agent and a tripwiring guardrail — on the merge base, with nothing applied:

streamed, base (bb3d64e7):  ['user']

Identical to what this PR makes run do. So this isn't a behaviour the PR invents; it's a pre-existing streamed bug that the parity argument propagates to run. That doesn't make it fine — you're right that for these agents it's worse than the current run behaviour, and a side effect with no session record is a real problem — but the fix probably belongs in both paths, and "streamed is the reference" can't be the justification for the tool case specifically.

On your second point, I could not reproduce it, and I think the example is already fixed here. My first three attempts were invalid — instrumenting save_resumed_turn_items showed CALLS: [], i.e. the resumed turn had looped back to the model rather than being the final-output turn, so the branch you cite never executed. With an approval-gated tool so resolve_interrupted_turn finishes the turn, the branch does run, and:

                                     save_resumed_turn_items CALLS: [['tool_call_output_item']]
base (bb3d64e7):  ['user', 'function_call', 'function_call_output', 'message']   rejected msgs: 1
this PR:          ['user', 'function_call', 'function_call_output']              rejected msgs: 0

564e67d5 appears to cover it: the deferred-save block re-checks _current_turn_persisted_item_count > 0 and routes accepted finals through save_resumed_turn_items after the guardrail, so the rejected message no longer lands. If you have a resumed scenario that still persists a rejected final, I'd want to see it — my read is that the ordering at run.py:922-938 is real but only reaches tool_call_output_item, not the rejected final output. Which, note, is the same over-capture as your first point rather than a separate bug: on this branch that resumed tool_call_output_item is still persisted, and identically so at base.

Method note so this is easy to re-check: every table above is A/B'd against bb3d64e7, and the instrumented CALLS: line is what distinguishes "the branch ran and behaved" from "the branch never ran" — three of my probes looked like clean non-repros purely because the code path wasn't reached.

A final-output turn produced by tool_use_behavior stop_on_first_tool,
stop_at_tool_names or a custom callable carries the tool call and its
output, so deferring the whole turn discarded the record of a side effect
that had already run. Defer only the model's own output for that turn and
persist everything else immediately, in the non-streamed, streamed and
resumed paths. The streamed path had the same gap before this change, so
matching it is no longer a justification for dropping tool items.

@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: 449c6a7e3a

ℹ️ 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".


await save_items(items, response_id, store_setting)
if deferred_items:
await save_items(deferred_items, response_id, store_setting)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve deferred items in mixed streamed final turns

When a streamed final turn contains both a message or reasoning item and an executed tool call, the first save_items(side_effect_items, ...) call increments the backing RunState._current_turn_persisted_item_count; this second call then passes only the shorter deferred subset to _save_stream_items_with_count, so save_result_to_session treats that entire subset as already persisted and saves nothing. Thus, when output guardrails pass, the session silently loses the accepted assistant message or reasoning item; save the original ordered batch on success or otherwise avoid applying the full-turn count to filtered subsets.

AGENTS.md reference: AGENTS.md:L118-L118

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both of these are real, and I can confirm them from the other side: I made exactly this mistake on my own branch an hour ago and had to back it out. No stake in this PR — @hsusul's call — but the evidence may save a round.

I filed #4148 for the pre-existing streamed half of this (a committed tool's session record is dropped when a streamed output guardrail trips). My first commit there split the save unconditionally, the same shape as here. Codex's P1 is right, and the mechanism is exactly as described.

Reproduction — mixed final turn (message emitted before the tool call, tool_use_behavior="stop_on_first_tool"), printing what lands in the session:

                      unconditional split      decision deferred
RUN    trip=False  -> user message fc fco       user message fc fco
RUN    trip=True   -> user message fc fco       user message fc fco
STREAM trip=False  -> user         fc fco  <--  user message fc fco
STREAM trip=True   -> user         fc fco       user         fc fco

The STREAM trip=False row is the P1: the accepted assistant message is silently lost. save_result_to_session computes

already_persisted = run_state._current_turn_persisted_item_count if run_state else 0
if already_persisted >= len(new_items):
    new_run_items = []

The first save advanced that count to the full turn length, so the shorter deferred subset trips already_persisted >= len(new_items) and nothing is written. It fails open — no exception, no log.

The P2 ordering claim holds for the same input: the model emitted message then function_call, and a two-phase save persists them in the reverse order, so a later run replays reordered history.

What worked. Defer the decision rather than the items — keep the success path as one unsplit save, and write a subset only on the tripwire path, which by definition is discarding the deliverable output anyway:

    try:
        output_guardrail_results = await _run_output_guardrails_for_stream(...)
    except Exception:
        committed = [i for i in items if i.type in _COMMITTED_ITEM_TYPES]
        if committed:
            await save_items(committed, response_id, store_setting)
        raise
    ...
    await save_items(items, response_id, store_setting)   # unchanged: full ordered batch

That fixes both claims at once: the passing path never sees a subset, so neither the count arithmetic nor the ordering can be perturbed by it.

Two notes if you go this route:

  • Allow-list the committed types, don't deny-list them. A future item type that records a side effect then defaults to being persisted rather than silently inheriting "discard on tripwire".
  • The regression test has to cover the passing mixed turn, not just the tripwire. My tripwire tests all passed while the P1 bug was live — it only shows up in trip=False. I ended up parametrizing over run/streamed × pass/trip; the pass rows are what caught it.

Full suite on my branch with the deferred-decision shape: 69 failed / 5482 passed vs 69 failed / 5474 passed at base, identical failing test-ID sets (compared as JUnit XML, since the raw count is flaky on Windows).

Comment thread src/agents/run.py
Comment on lines +1440 to +1444
if isinstance(turn_result.next_step, NextStepFinalOutput):
items_now_turn = [
item
for item in items_to_save_turn
if item.type not in _DEFERRED_FINAL_OUTPUT_ITEM_TYPES

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve item order when final output passes guardrails

When a successful non-streamed final turn contains a message or reasoning item before a tool call, this split persists the tool call and output first and appends the earlier model item only after guardrails pass. The resulting session order no longer matches the model response, so a subsequent run replays reordered history; defer the decision rather than the individual items so the complete original batch can be appended in order on success, while saving only executed side-effect records on rejection.

AGENTS.md reference: AGENTS.md:L128-L128

Useful? React with 👍 / 👎.

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.

5 participants