-
Notifications
You must be signed in to change notification settings - Fork 4.5k
fix: defer session save until after output guardrails #3998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -414,6 +414,12 @@ async def _run_output_guardrails_for_stream( | |
| raise | ||
|
|
||
|
|
||
| # Only the model's own output for the final turn waits for the output-guardrail verdict. | ||
| # This is an allow-list on purpose: a future item type that records something the run | ||
| # already did defaults to "persist" rather than silently inheriting "discard". | ||
| _DEFERRED_FINAL_OUTPUT_ITEM_TYPES = frozenset({"message_output_item", "reasoning_item"}) | ||
|
|
||
|
|
||
| async def _finalize_streamed_final_output( | ||
| *, | ||
| streamed_result: RunResultStreaming, | ||
|
|
@@ -426,6 +432,15 @@ async def _finalize_streamed_final_output( | |
| response_id: str | None, | ||
| store_setting: bool | None, | ||
| ) -> None: | ||
| # Tool calls and their outputs record side effects that already happened, so they persist | ||
| # before the guardrails can raise. Only the model's own output waits for a clean verdict. | ||
| side_effect_items = [ | ||
| item for item in items if item.type not in _DEFERRED_FINAL_OUTPUT_ITEM_TYPES | ||
| ] | ||
| deferred_items = [item for item in items if item.type in _DEFERRED_FINAL_OUTPUT_ITEM_TYPES] | ||
| if side_effect_items: | ||
| await save_items(side_effect_items, response_id, store_setting) | ||
|
|
||
| output_guardrail_results = await _run_output_guardrails_for_stream( | ||
| agent=agent, | ||
| run_config=run_config, | ||
|
|
@@ -437,7 +452,8 @@ async def _finalize_streamed_final_output( | |
| streamed_result.final_output = output | ||
| streamed_result.is_complete = True | ||
|
|
||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a streamed final turn contains both a message or reasoning item and an executed tool call, the first AGENTS.md reference: AGENTS.md:L118-L118 Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, The 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 The P2 ordering claim holds for the same input: the model emitted 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:
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). |
||
|
|
||
| streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.