Repository navigation
Description
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.
Activity