Skip to content

Resumed terminal tool output cannot recover after a Session append failure #4690

Description

@FU-max-boop

Please read this first

  • I read the Session, RunState, approval, lifecycle, and output-guardrail docs.
  • I searched open and closed issues/PRs for Session append,
    NextStepFinalOutput, and terminal tool-output persistence. The related work
    listed under Scope boundaries does not cover this terminal-finalization
    failure mode.

Describe the bug

After an approval-gated local tool completes, tool_use_behavior="stop_on_first_tool"
can make that tool output the terminal agent output. If the final client-managed
Session.add_items() append then fails, the exception is correctly propagated,
but the resulting RunState cannot recover or explicitly reject that accepted
terminal result.

At the failure boundary:

  • the live runtime step is NextStepFinalOutput;
  • JSON serialization records current_step = null;
  • there is no pending_session_write;
  • the passing output guardrail has already evaluated the original output, but
    its result is not retained in the failed state.

Retrying the same live or JSON-restored state therefore enters the model again,
runs the output guardrail again, and returns a new final output. The completed
tool effect is not repeated in this reproducer, but terminal output identity,
guardrail evidence, and durable Session history can diverge.

Debug information

  • Agents SDK: latest release v0.22.0, and exact current
    main@a40ae9803e6b7a79faa246293f56adb100d5868b
  • Exact src tree: 56eeb964e8924a6d0034b3e2289699c719f5450b
  • Python: 3.13.15
  • Operating system: macOS 26.5, arm64
  • Model/provider: deterministic public ScriptedModel; no provider, API key,
    network request, or timing race
  • Latest release: yes
  • Reproducibility: consistent; focused repro and all 16 frozen matrix rows

The injected exception is:

RuntimeError: injected Session.add_items failure before commit

Repro steps

This focused fail-before-commit case is self-contained and also reproduces
against the installed openai-agents==0.22.0 package:

Reproducer
import asyncio
import copy
import json
from typing import Any

from openai.types.responses import (
    ResponseFunctionToolCall,
    ResponseOutputMessage,
    ResponseOutputText,
)
from agents import (
    Agent,
    GuardrailFunctionOutput,
    RunContextWrapper,
    Runner,
    function_tool,
    output_guardrail,
)
from agents.run import RunConfig
from agents.testing import ScriptedModel


class FailingSession:
    def __init__(self):
        self.session_id = "terminal-write-repro"
        self.items = []
        self.fail_next = False

    async def get_items(self, limit=None):
        items = copy.deepcopy(self.items)
        return items[-limit:] if limit is not None else items

    async def add_items(self, items):
        if self.fail_next:
            self.fail_next = False
            raise RuntimeError("injected Session.add_items failure before commit")
        self.items.extend(copy.deepcopy(items))

    async def pop_item(self):
        return self.items.pop() if self.items else None

    async def clear_session(self):
        self.items.clear()


async def main():
    effects = []
    guardrail_outputs = []

    @function_tool(needs_approval=True)
    async def charge(amount: int) -> str:
        effects.append(amount)
        return f"receipt-{amount}"

    @output_guardrail
    async def record_output(
        context: RunContextWrapper[Any], agent: Agent[Any], output: Any
    ) -> GuardrailFunctionOutput:
        guardrail_outputs.append(str(output))
        return GuardrailFunctionOutput(output_info=output, tripwire_triggered=False)

    model = ScriptedModel(
        steps=[
            [
                ResponseFunctionToolCall(
                    id="fc-1",
                    call_id="charge-1",
                    type="function_call",
                    name="charge",
                    arguments=json.dumps({"amount": 7}),
                )
            ],
            [
                ResponseOutputMessage(
                    id="msg-2",
                    type="message",
                    role="assistant",
                    status="completed",
                    content=[
                        ResponseOutputText(
                            type="output_text",
                            text="retry-final",
                            annotations=[],
                            logprobs=[],
                        )
                    ],
                )
            ],
        ]
    )
    agent = Agent(
        name="terminal-write-repro",
        model=model,
        tools=[charge],
        tool_use_behavior="stop_on_first_tool",
        output_guardrails=[record_output],
    )
    session = FailingSession()
    config = RunConfig(tracing_disabled=True)

    paused = await Runner.run(agent, "charge 7", session=session, run_config=config)
    state = paused.to_state()
    state.approve(paused.interruptions[0])

    session.fail_next = True
    try:
        await Runner.run(agent, state, session=session, run_config=config)
    except RuntimeError as error:
        print(type(error).__name__, str(error))

    serialized = state.to_json()
    print(
        {
            "runtime_step": type(state._current_step).__name__,
            "serialized_step": serialized.get("current_step"),
            "pending_session_write": serialized.get("pending_session_write"),
            "retained_output_guardrails": len(state._output_guardrail_results),
            "model_calls": len(model.calls),
            "effects": effects,
            "guardrail_outputs": guardrail_outputs,
        }
    )

    retried = await Runner.run(agent, state, session=session, run_config=config)
    print(
        {
            "retry_output": retried.final_output,
            "model_calls": len(model.calls),
            "effects": effects,
            "guardrail_outputs": guardrail_outputs,
        }
    )


asyncio.run(main())

Observed output:

RuntimeError injected Session.add_items failure before commit
{'runtime_step': 'NextStepFinalOutput', 'serialized_step': None,
 'pending_session_write': None, 'retained_output_guardrails': 0,
 'model_calls': 1, 'effects': [7], 'guardrail_outputs': ['receipt-7']}
{'retry_output': 'retry-final', 'model_calls': 2, 'effects': [7],
 'guardrail_outputs': ['receipt-7', 'retry-final']}

Frozen behavior matrix

I also froze a 16-row Cartesian product before inspecting results:

Dimension Values
failing resume runner sync, stream
retry runner sync, stream
retry state same live object, JSON round trip
append failure fail before commit, commit then raise (lost acknowledgement)

The tool is charge(amount=7), its exact output is receipt-7, and a second
scripted response retry-final exists only to expose a new model call.

Observation Rows
injected append failure propagated 16 / 16
retry completed 16 / 16
tool effect and tool start/end hooks remained exactly once 16 / 16
first output guardrail evaluated receipt-7 before failure 16 / 16
pending terminal Session checkpoint retained after failure 0 / 16
first passing output-guardrail result retained after failure 0 / 16
original receipt-7 recovered without another model call 0 / 16
retry made a second model call and returned retry-final 16 / 16
durable Session contained one exact call/output pair after retry 8 / 16
public replay and retry model input contained one exact pair 16 / 16

The eight durable-Session successes are only the append-then-raise rows: the
pair had already committed before the simulated lost acknowledgement. Those
rows still make a second model call because no terminal checkpoint recognizes
the committed batch as the already accepted result. In all eight
fail-before-commit rows, the Session lacks the tool call/output even after
retry, while replay/model input contain the pair and the Session contains the
later assistant message.

Relevant control flow

At the pinned commit:

Expected behavior / design question

I do not want to force terminal finalization into NextStepRunAgain: the
matrix shows that doing so re-enters the model and changes an already accepted
terminal result.

Would maintainers prefer one of these contracts?

  1. Recoverable terminal continuation. Persist enough state to own the
    pending terminal batch, accepted final output, and completed guardrail
    results; reconcile the same Session before any model call. An unchanged
    fail-before tail can append and finalize, and a provable lost acknowledgement
    can finalize without repeating model/tool/hooks/guardrails.
  2. Explicitly non-resumable terminal failure. Preserve an actionable,
    serializable recovery payload, but reject subsequent Runner.run*() calls
    before model execution instead of silently treating the state as run-again.

For either choice, changed, partial, or concurrently modified Session history
should fail closed rather than guess. Sync/stream and live/JSON recovery should
have the same contract.

I can prepare the focused implementation and regression matrix after the
contract is selected.

Scope boundaries

Evidence integrity

The full provider-neutral harness was run twice with byte-identical JSON and
JSONL results. Its standard-library verifier reconstructs the pinned Git tree,
checks all 315 source files and every trace/state/Session/model cross-field
relationship, and rejects re-sealed source/trace/state/package-boundary
mutations in negative tests.

  • source snapshot SHA-256:
    5779a3d6d2e2e94b078d85785b0886d470636cd7513b4946d35c46340f448417
  • baseline JSON SHA-256:
    5f4102aef87b7875f61562ac6f649437fc711edfb29f92ae5815e1665ff757ae
  • baseline JSONL SHA-256:
    49e7bca13ca4d1fd155ab34b3ca38a8734f9c0ba5d06ffd06e11100ae20d27c1
  • external manifest root SHA-256:
    f53612af88d0180b55bfb58ea5fe12cf8db251b583c9050e8b655010d28aad46

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions