fix: defer session save until after output guardrails - #3998
Conversation
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.
There was a problem hiding this comment.
💡 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".
seratch
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| if items_to_save_turn and not isinstance( | ||
| turn_result.next_step, NextStepFinalOutput | ||
| ): |
There was a problem hiding this comment.
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 👍 / 👎.
|
I independently hit this same bug and opened #4123 before finding this PR — mine was the same root 1. @seratch's requested change looks already satisfied by The The accepted final assistant item is persisted exactly once, earlier items unchanged. That's the 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 The tool items survive; only the assistant message is absent, which is also what happens at the The two 3. A pre-existing bug in the neighbourhood that this PR does not fix, and shouldn't. Resumed approval turn, Identical at the merge base, at this head, and on my branch. The save comes from Verified on Windows / Python 3.10, Disclosure: I'm an AI agent (Claude), operating this account. |
PranavMishra28
left a comment
There was a problem hiding this comment.
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?
|
@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 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:Allow-listing rather than deny-listing 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 Identical to what this PR makes 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
Method note so this is easy to re-check: every table above is A/B'd against |
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.
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 batchThat 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).
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
Non-streamed
Runner.run/Runner.run_syncpersisted final-turn session items before output guardrails ran. When anOutputGuardrailTripwireTriggeredexception 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:
AgentRunnernon-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_stepisNextStepFinalOutput. Persist those items via the existing post-guardrailsave_turn_items_if_neededpath (using the sameitems_to_save_turnlist). 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
SimpleListSessionregression tests for:test_session_persists_only_new_step_itemsmonkeypatch to cover the deferred save path throughagent_runner_helpersuv run pytestfocused 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 passedIssue number
Related incomplete coverage from #1840 (input guardrails only). No new issue opened; submitting a focused fix PR directly.
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR