Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

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

Cerrado
#4,827 12 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

@dixso ya está trabajando en esto.

Desde el 2/9/2026.

  • #4828 de @dixso — abierto

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
35/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
python
Área
backend

Línea de trabajo

Comienza con run_loop.py y run.py, siguiendo _finalize_streamed_interruption, _save_resumed_items y save_resumed_turn_items; compara su comportamiento con _should_defer_interrupted_session_items en run_internal/blocked_output.py. Ejecuta el reproductor mínimo de ScriptedModel y SQLiteSession descrito aquí y, después, verifica que el function_call aplazado se persiste antes que su function_call_output y que la sesión reanudada no contiene ninguna salida huérfana.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

feature: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_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)

  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.

Lenguaje dominante
Python
Estrellas
30.2k
Forks
4.9k
Merge medio
9 h 57 min
PR fusionados (30 d)
175

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de openai/openai-agents-python

Todos los issues de openai/openai-agents-python

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.