Skip to content

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

Description

@dixso

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

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)

  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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions