Deferred interrupted-turn session items are never persisted when an approval resume goes through next_step_run_again, leaving an orphaned function_call_output

Author: dixsoCreated Sep 2, 2026Updated Sep 15, 2026
Labelsfeature:sessions

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_itemssave_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.

python
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)

  1. 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.
  2. 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.
  3. 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.

Source: openai/openai-agents-python