openai / openai/openai-agents-python
Deferred interrupted-turn session items are never persisted when an approval resume goes through next_step_run_again, leaving an orphaned function_call_output
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 29.6k
- Forks
- 4.8k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 123
Description
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
- Agents SDK version:
main@ 89c02c82 (also affects released 0.22.0, which fails earlier with #4611) - Python version: 3.12
- Operating system: Linux (python:3.12-slim container) and macOS
- Model and model provider: none needed,
agents.testing.ScriptedModel - Does the issue reproduce with the latest Agents SDK release? 0.22.0 cannot reach it: the resume dies first with #4611. On
main(where #4613 fixed that) it reproduces. - Does the issue occur consistently or intermittently? Consistently, 100% deterministic.
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 @ 89c02c82 |
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 (89c02c82):
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-defaulttool_use_behavior, the interrupted turn's items (thefunction_calland the
approval item) are deferred:RunState._session_itemscarries them,
current_turn_persisted_item_count == 0. - Resume after
state.approve(...):resolve_interrupted_turnexecutes the tool; next step
isnext_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 tool
output only. The deferredfunction_callis inrun_state._session_itemsbut 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with run_loop.py and run.py, following _finalize_streamed_interruption, _save_resumed_items, and save_resumed_turn_items; compare their behavior with _should_defer_interrupted_session_items in run_internal/blocked_output.py. Run the minimal ScriptedModel and SQLiteSession reproducer described here, then verify the deferred function_call is persisted before its function_call_output and the resumed session contains no orphaned output.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100