Skip to content

Serialized approval checkpoints cannot be resumed with output guardrails when the interruption follows an earlier model response #4611

Closed

Description

@AAliKKhan

Describe the bug

When an agent has output guardrails and a tool-approval interruption occurs on a later model response (i.e., after any earlier completed model response in the same run), every serialized checkpoint of that run fails to resume:

UserError: Cannot resume a serialized approval checkpoint with output guardrails because the current response boundary cannot be proven. Start a new run from safe input.

This fires deterministically on RunState.from_string(...) + Runner.run(agent, state) - non-streamed, streamed, with or without a session. Resuming the identical flow in-memory works fine, so this breaks exactly the durable cross-process pause/resume boundary that RunState.to_string() exists for.

Debug information

  • Agents SDK version: 0.22.0 (also present on current main)
  • Python 3.13, any OS
  • Repro is offline (ScriptedModel), no API access needed

Repro

import asyncio

from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText

from agents import Agent, RunContextWrapper, RunState, Runner, function_tool, guardrail
from agents.memory import SQLiteSession
from agents.testing import ScriptedModel


def get_text_message(text):
    return ResponseOutputMessage(
        id="msg_x",
        content=[ResponseOutputText(text=text, type="output_text", annotations=[])],
        role="assistant",
        type="message",
        status="completed",
    )


def get_function_tool_call(name, arguments, call_id):
    return ResponseFunctionToolCall(
        id="fc_x", arguments=arguments, name=name, type="function_call", call_id=call_id
    )


async def scenario(name, *, serialize=True, streamed=False, session=False):
    model = ScriptedModel()

    @function_tool(name_override="normal_tool")
    def normal_tool() -> str:
        return "normal ok"

    @function_tool(name_override="approval_tool", needs_approval=True)
    def approval_tool() -> str:
        return "approved ok"

    @guardrail.output_guardrail
    def passthrough(ctx: RunContextWrapper, agent: Agent, output) -> guardrail.GuardrailFunctionOutput:
        return guardrail.GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)

    agent = Agent(
        name="test",
        model=model,
        tools=[normal_tool, approval_tool],
        output_guardrails=[passthrough],
    )
    model.extend(
        [
            [get_function_tool_call("normal_tool", "{}", "call_normal")],  # turn 1
            [get_function_tool_call("approval_tool", "{}", "call_appr")],  # turn 2 -> interruption
            [get_text_message("done")],
        ]
    )

    sess = SQLiteSession(f"probe_{name}") if session else None

    first = await Runner.run(agent, input="hello", session=sess)
    assert len(first.interruptions) == 1
    state = first.to_state()
    state.approve(first.interruptions[0])

    restored = await RunState.from_string(agent, state.to_string()) if serialize else state

    try:
        if streamed:
            result = Runner.run_streamed(agent, restored, session=sess)
            async for _ in result.stream_events():
                pass
            resumed = result
        else:
            resumed = await Runner.run(agent, restored, session=sess)
        print(f"[{name}] OK final_output={resumed.final_output!r}")
    except Exception as e:
        print(f"[{name}] FAILED: {type(e).__name__}: {e}")


async def main():
    await scenario("inmem-nostream", serialize=False)                 # OK
    await scenario("serialized-nostream")                             # UserError
    await scenario("serialized-streamed", streamed=True)              # UserError
    await scenario("serialized-session", session=True)                # UserError
    await scenario("serialized-session-streamed", streamed=True, session=True)  # UserError


asyncio.run(main())

Observed output:

[inmem-nostream] OK final_output='done'
[serialized-nostream] FAILED: UserError: Cannot resume a serialized approval checkpoint ...
[serialized-streamed] FAILED: UserError: Cannot resume a serialized approval checkpoint ...
[serialized-session] FAILED: UserError: Cannot resume a serialized approval checkpoint ...
[serialized-session-streamed] FAILED: UserError: Cannot resume a serialized approval checkpoint ...

Root cause

_current_response_boundary() in src/agents/run_internal/blocked_output.py proves which generated items belong to the current response using exact object identity (_identity_sequence_start). Serialization rebuilds every item, destroying that identity, so both the generated-items and session-items identity lookups fail.

The code already acknowledges this problem - there is a structural fallback for deserialized items - but it is gated on _current_turn == 1:

# Serialization rebuilds item identities, but turn one has no accepted prefix.

A natural multi-turn run (approval requested on turn >= 2 after earlier tool calls) falls through to proven=False, so _validate_resumed_session_output_guardrail_safety() raises on every resume. The existing test coverage only asserts this error for a hand-mutated ambiguous state, not for a naturally serialized multi-turn checkpoint.

Expected behavior

A serialized approval checkpoint taken mid-run should be resumable when the response boundary is reconstructible from the snapshot itself, regardless of turn number - matching the in-memory path.

Suggested direction

Persist/restore enough information in the snapshot to prove the boundary positionally (for example, the index offset of processed_response.new_items within _generated_items) instead of relying on object identity alone.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions