Summary
Since the blocked-output deferral landed (#4507, released in 0.22.0), an agent that has
both output_guardrails and tool_use_behavior != "run_llm_again" defers the
interrupted turn's session items when a needs_approval tool parks
(_should_defer_interrupted_session_items in run_internal/blocked_output.py). The park-time
session write for that turn is [] by design.
But when the approval is resumed and the approved tool is not a terminal tool (so the
resolved turn's next step is next_step_run_again), no code path ever persists the deferred
items: the resume-side write (_save_resumed_items → save_resumed_turn_items) only carries
the resolved turn's new_step_items (the tool output), and the final-output sweep
(_final_turn_items_for_persistence) only covers the final response's items.
The result in the Session: a function_call_output whose function_call was never written.
On the next Runner.run(..., session=session) the provider rejects the whole conversation:
openai.BadRequestError: Error code: 400 - No tool call found for function call
output with call_id call_ORPHAN
Since the orphan is durable in the Session, every subsequent turn fails the same way; the
conversation is permanently dead.
Debug information
Version matrix (same reproducer, three versions)
| Version |
Outcome |
| 0.21.1 |
OK: the parked function_call is written at interruption time, pair complete |
| 0.22.0 |
resume dies first with UserError: Cannot resume a serialized approval checkpoint with output guardrails… (#4611) |
main @ 89c02c8 |
resume proceeds (#4613), tool executes, but the parked function_call is never persisted → orphaned output, session permanently rejected by the API |
So 0.22.0 hid this behind #4611; #4613 unblocks the resume and exposes it.
Minimal reproducer
No API key, no network (ScriptedModel, SQLiteSession). The only thing it does beyond
tests/test_hitl_session_scenario.py is (a) the agent has output_guardrails and
StopAtTools, matching the deferral gate, and (b) the RunState round-trips through JSON,
as any app that parks the approval in its own store (Redis, a DB row) and resumes in a later
process must do.
import asyncio
import json
from agents import (
Agent,
GuardrailFunctionOutput,
Runner,
RunState,
SQLiteSession,
StopAtTools,
function_tool,
output_guardrail,
)
from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call
@function_tool(name_override="write_thing", needs_approval=True)
def write_thing(query: str) -> str:
return f"wrote:{query}"
@function_tool(name_override="look_up", needs_approval=False)
def look_up(query: str) -> str:
return f"schema for {query}"
@output_guardrail
async def always_fine(ctx, agent, output) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
def make_model() -> ScriptedModel:
# Two model turns, like a real agent: an ungated lookup first, THEN the gated write.
return ScriptedModel(
[
ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]),
ModelStep(output=[function_call("write_thing", {"query": "x"}, call_id="call_ORPHAN")]),
ModelStep(output=[assistant_message("done")]),
]
)
async def run_streamed(agent, inp, session):
result = Runner.run_streamed(agent, inp, session=session)
async for _ in result.stream_events():
pass
return result
async def main() -> None:
session = SQLiteSession("repro", ":memory:")
agent = Agent(
name="repro",
instructions="Always call write_thing.",
model=make_model(),
tools=[look_up, write_thing],
# The two conditions that open _should_defer_interrupted_session_items:
# output guardrails AND tool_use_behavior != "run_llm_again". The approved
# tool is NOT in the stop list, so the resume goes through next_step_run_again.
output_guardrails=[always_fine],
tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]),
)
first = await run_streamed(agent, "do the thing", session)
assert len(first.interruptions) == 1
# Park in an external store and resume from it, as a multi-process app must.
serialized = json.dumps(first.to_state().to_json())
state = await RunState.from_json(agent, json.loads(serialized))
state.approve(state.get_interruptions()[0])
await run_streamed(agent, state, session)
items = await session.get_items()
calls = {i.get("call_id") for i in items if i.get("type") == "function_call"}
outputs = [i for i in items if i.get("type") == "function_call_output"]
orphans = [o for o in outputs if o.get("call_id") not in calls]
for i in items:
print(f" {i.get('type') or i.get('role'):22} {i.get('call_id', '')}")
print(f"ORPHANED OUTPUTS: {len(orphans)}")
assert not orphans, "the Session now poisons every future run with a 400"
asyncio.run(main())
Output on main (89c02c8):
user
function_call call_LOOKUP
function_call_output call_LOOKUP
function_call_output call_ORPHAN <-- its function_call was never written
message
ORPHANED OUTPUTS: 1
Expected (and what 0.21.1 does):
user
function_call call_LOOKUP
function_call_output call_LOOKUP
function_call call_ORPHAN
function_call_output call_ORPHAN
message
Mechanism (as far as we traced it)
- Park:
run_loop.py → _finalize_streamed_interruption(items=[] if _should_defer_interrupted_session_items(...) else turn_session_items). With guardrails +
non-default tool_use_behavior, the interrupted turn's items (the function_call and the
approval item) are deferred: RunState._session_items carries them,
current_turn_persisted_item_count == 0.
- Resume after
state.approve(...): resolve_interrupted_turn executes the tool; next step
is next_step_run_again (approved tool is not terminal). The write that runs is
_save_resumed_items(list(turn_session_items)) where turn_session_items = session_items_for_turn(turn_result) = the resolved turn's new_step_items = the tool
output only. The deferred function_call is in run_state._session_items but is
never part of any write.
- The final output arrives on a later model response, so
_final_turn_items_for_persistence (the deferral's final sweep) only considers that
response's items and cannot recover the earlier deferred call.
Note the non-streamed Runner.run resume path has the same shape (run.py, the
save_resumed_turn_items call guarded by the same _should_defer_interrupted_session_items).
Impact
Any app that (a) uses needs_approval tools with a client-managed Session, (b) has output
guardrails, (c) uses StopAtTools or any non-default tool_use_behavior, and (d) resumes an
approval where the approved tool is not terminal, ends up with a Session the API permanently
rejects. The failure is delayed (the approving turn itself succeeds), which makes it hard to
trace back.
Fix
We have a candidate fix with a regression test, verified against our integration and against
the full test suite (no new failures): once the resume commits to continuing the run
(run-again / handoff), it persists the deferred prefix — located via the already-computed
resumed response boundary — ahead of the resolved turn's items, in both the streamed and
non-streamed paths. PR incoming right after this issue; happy to adjust it to whatever design
you prefer.
Summary
Since the blocked-output deferral landed (#4507, released in 0.22.0), an agent that has
both
output_guardrailsandtool_use_behavior != "run_llm_again"defers theinterrupted turn's session items when a
needs_approvaltool parks(
_should_defer_interrupted_session_itemsinrun_internal/blocked_output.py). The park-timesession write for that turn is
[]by design.But when the approval is resumed and the approved tool is not a terminal tool (so the
resolved turn's next step is
next_step_run_again), no code path ever persists the deferreditems: the resume-side write (
_save_resumed_items→save_resumed_turn_items) only carriesthe resolved turn's
new_step_items(the tool output), and the final-output sweep(
_final_turn_items_for_persistence) only covers the final response's items.The result in the Session: a
function_call_outputwhosefunction_callwas never written.On the next
Runner.run(..., session=session)the provider rejects the whole conversation:Since the orphan is durable in the Session, every subsequent turn fails the same way; the
conversation is permanently dead.
Debug information
main@ 89c02c8 (also affects released 0.22.0, which fails earlier with Serialized approval checkpoints cannot be resumed with output guardrails when the interruption follows an earlier model response #4611)agents.testing.ScriptedModelmain(where fix: preserve serialized approval resume ownership #4613 fixed that) it reproduces.Version matrix (same reproducer, three versions)
function_callis written at interruption time, pair completeUserError: Cannot resume a serialized approval checkpoint with output guardrails…(#4611)main@ 89c02c8function_callis never persisted → orphaned output, session permanently rejected by the APISo 0.22.0 hid this behind #4611; #4613 unblocks the resume and exposes it.
Minimal reproducer
No API key, no network (
ScriptedModel,SQLiteSession). The only thing it does beyondtests/test_hitl_session_scenario.pyis (a) the agent hasoutput_guardrailsandStopAtTools, matching the deferral gate, and (b) theRunStateround-trips through JSON,as any app that parks the approval in its own store (Redis, a DB row) and resumes in a later
process must do.
Output on
main(89c02c8):Expected (and what 0.21.1 does):
Mechanism (as far as we traced it)
run_loop.py→_finalize_streamed_interruption(items=[] if _should_defer_interrupted_session_items(...) else turn_session_items). With guardrails +non-default
tool_use_behavior, the interrupted turn's items (thefunction_calland theapproval item) are deferred:
RunState._session_itemscarries them,current_turn_persisted_item_count == 0.state.approve(...):resolve_interrupted_turnexecutes the tool; next stepis
next_step_run_again(approved tool is not terminal). The write that runs is_save_resumed_items(list(turn_session_items))whereturn_session_items = session_items_for_turn(turn_result)= the resolved turn'snew_step_items= the tooloutput only. The deferred
function_callis inrun_state._session_itemsbut isnever part of any write.
_final_turn_items_for_persistence(the deferral's final sweep) only considers thatresponse's items and cannot recover the earlier deferred call.
Note the non-streamed
Runner.runresume path has the same shape (run.py, thesave_resumed_turn_itemscall guarded by the same_should_defer_interrupted_session_items).Impact
Any app that (a) uses
needs_approvaltools with a client-managed Session, (b) has outputguardrails, (c) uses
StopAtToolsor any non-defaulttool_use_behavior, and (d) resumes anapproval where the approved tool is not terminal, ends up with a Session the API permanently
rejects. The failure is delayed (the approving turn itself succeeds), which makes it hard to
trace back.
Fix
We have a candidate fix with a regression test, verified against our integration and against
the full test suite (no new failures): once the resume commits to continuing the run
(run-again / handoff), it persists the deferred prefix — located via the already-computed
resumed response boundary — ahead of the resolved turn's items, in both the streamed and
non-streamed paths. PR incoming right after this issue; happy to adjust it to whatever design
you prefer.