Skip to content

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

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

fix: defer session save until after output guardrails#3998
hsusul wants to merge 2 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
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.

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.

3 participants