Skip to content

RunState retry after an approved tool's Session write fails leaves an orphan tool call #4615

Description

@FU-max-boop

Describe the bug

When an approved local tool has already executed but persisting its output to a client-managed Session fails, the RunState advances to next_step_run_again before the Session write succeeds.

Retrying the same live or serialized state correctly avoids executing the tool twice, but it never retries the missing Session write. The next model call proceeds and the final assistant message is persisted, leaving the Session with a function_call that has no matching function_call_output.

This creates two conflicting conversation views:

  • RunState and RunResult.to_input_list() contain the completed call/output pair.
  • The durable Session remains missing the output after the SDK retry.

A later fresh run using that Session prunes the orphaned call from model input, so the completed action disappears from future conversation context.

The reproducer is provider-neutral, deterministic, and makes no API calls.

Debug information

  • Agents SDK versions: v0.20.0, v0.21.0, v0.22.0, and current main at fe45b415ee05479725cd6fb20a51c0d5cd73b3c1
  • Python version: 3.12.13
  • Operating system: macOS 15.6.1 arm64; no platform-specific code is involved
  • Model and provider: ScriptedModel; no provider or API key
  • Consistency: deterministic
Ref Commit Result
v0.20.0 d2bda3f3110415bf02e526a3983b0d0fa903e0d7 Reproduces
v0.21.0 25aa6d94a1b9048772893d480fb467c3177ccd66 Reproduces
v0.22.0 4df9ecfae1761ca6fea67cc5a20b383c1d492024 Reproduces
main fe45b415ee05479725cd6fb20a51c0d5cd73b3c1 Reproduces

For v0.20.0, I used the repository's deterministic tests.fake_model.FakeModel; later versions and main used agents.testing.ScriptedModel.

Repro steps

import asyncio
import json
from typing import Any

from agents import Agent, RunConfig, RunState, Runner, function_tool
from agents.testing import ScriptedModel
from openai.types.responses import (
    ResponseFunctionToolCall,
    ResponseOutputMessage,
    ResponseOutputText,
)


class FailOnceSession:
    """A Session whose failed add is atomic: the rejected batch is not stored."""

    def __init__(self) -> None:
        self.session_id = "session-1"
        self.items: list[dict[str, Any]] = []
        self.fail_next_add = False

    async def get_items(self, limit: int | None = None) -> list[dict[str, Any]]:
        items = list(self.items)
        return items[-limit:] if limit is not None else items

    async def add_items(self, items: list[dict[str, Any]]) -> None:
        if self.fail_next_add:
            self.fail_next_add = False
            raise RuntimeError("injected atomic Session.add_items failure")
        self.items.extend(items)

    async def pop_item(self) -> dict[str, Any] | None:
        return self.items.pop() if self.items else None

    async def clear_session(self) -> None:
        self.items.clear()


def count(items: list[dict[str, Any]], item_type: str) -> int:
    return sum(
        item.get("type") == item_type and item.get("call_id") == "call-charge"
        for item in items
    )


async def main() -> None:
    model = ScriptedModel(
        steps=[
            [
                ResponseFunctionToolCall(
                    id="fc-1",
                    call_id="call-charge",
                    type="function_call",
                    name="charge",
                    arguments=json.dumps({"amount": 7}),
                )
            ],
            [
                ResponseOutputMessage(
                    id="msg-1",
                    type="message",
                    role="assistant",
                    content=[
                        ResponseOutputText(
                            type="output_text",
                            text="done",
                            annotations=[],
                            logprobs=[],
                        )
                    ],
                    status="completed",
                )
            ],
        ]
    )

    effects: list[int] = []

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

    agent = Agent(name="agent", model=model, tools=[charge])
    session = FailOnceSession()
    config = RunConfig(tracing_disabled=True)

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

    session.fail_next_add = True
    try:
        await Runner.run(agent, state, session=session, run_config=config)
    except RuntimeError as error:
        print("first resume:", error)

    print("step after failure:", state.to_json()["current_step"]["type"])
    print("effects after failure:", effects)

    # A JSON round-trip here produces the same result:
    # state = await RunState.from_json(agent, state.to_json())

    resumed = await Runner.run(agent, state, session=session, run_config=config)
    replay = resumed.to_input_list()

    print("final output:", resumed.final_output)
    print("effects after retry:", effects)
    print(
        "Session call/output:",
        count(session.items, "function_call"),
        count(session.items, "function_call_output"),
    )
    print(
        "Replay call/output:",
        count(replay, "function_call"),
        count(replay, "function_call_output"),
    )


asyncio.run(main())

Observed output on the pinned main head:

first resume: injected atomic Session.add_items failure
step after failure: next_step_run_again
effects after failure: [7]
final output: done
effects after retry: [7]
Session call/output: 1 0
Replay call/output: 1 1

I also ran each phase independently through Runner.run() or Runner.run_streamed(). Every combination produced one tool side effect, Session call/output counts of 1 / 0, replay counts of 1 / 1, and final output done:

Initial pause Failing resume Retry
run run run
run run run_streamed
run run_streamed run
run run_streamed run_streamed
run_streamed run run
run_streamed run run_streamed
run_streamed run_streamed run
run_streamed run_streamed run_streamed

Expected behavior

After the first persistence failure:

  1. The original Session exception should still propagate.
  2. The completed tool side effect must not execute again.
  3. The failed state must not remain silently resumable into another model call while the Session is missing the completed tool output.
  4. A retry should either reconcile the missing batch without re-executing the tool, or raise an actionable non-resumable error before the next model call.

After successful reconciliation, the Session, RunState, model input, and public replay view should all contain one call/output pair. If reconciliation is unsupported, the failure should remain terminal and actionable.

Actual behavior

The tool side effect and replay safety are preserved, but Session durability is not:

  1. The tool executes and produces its output.
  2. RunState advances to next_step_run_again.
  3. Session.add_items() raises before storing the output.
  4. The error propagates, but the advanced state remains reusable.
  5. Retrying from that state skips interruption resolution and goes directly to the next model call.
  6. No path reconstructs or retries the missing Session batch.
  7. The final assistant message is stored after the orphaned call.

The run therefore appears successful while the durable history remains incomplete.

Root cause

The non-streaming path updates state before it awaits the resumed Session write:

  • src/agents/run.py:1117-1135: build resumed items and advance RunState.
  • src/agents/run.py:1151-1163: attempt the Session write.

The streaming path has the same ordering:

  • src/agents/run_internal/run_loop.py:1322-1351: update the resumed state.
  • src/agents/run_internal/run_loop.py:1438-1444: attempt the NextStepRunAgain Session write.

After the exception, the state already says NextStepRunAgain. A later resume no longer has the original turn_session_items, and save_resumed_turn_items() only persists items supplied by the current call. There is no durable pending-write phase to replay or reconcile.

The existing happy-path tests verify that approved outputs are normally persisted and not duplicated, but they do not cover an atomic append failure followed by retry:

  • tests/test_run_impl_resume_paths.py::test_resumed_approval_does_not_duplicate_session_items
  • tests/test_agent_runner_streamed.py::test_streaming_resume_persists_tool_outputs_on_run_again

Proposed scope contract

For the same Session ID and underlying backend, when an atomic Session append fails after an approved local tool has completed, retrying the same live or JSON-restored RunState should:

  • execute the tool side effect exactly once;
  • persist its call/output evidence exactly once;
  • prevent the next model call until persistence is successful or already committed.

The change should preserve propagation of the original persistence error, current no-reexecution behavior, successful resume behavior, streaming/non-streaming/cross-mode parity, approval decisions, tool identity, guardrails, and public replay.

Unless a stronger contract is chosen, a first implementation can fail closed before the next model call for a non-atomic backend, a different Session ID/backend, concurrent resumes of one state, or an ambiguous Session tail that cannot prove whether the batch committed.

There is no adequate public recovery API today. An application can inspect private RunState fields and manually repair the Session, but that depends on internal item conversion, filtering, and occurrence semantics.

Design question

Should Session persistence after resumed local side effects become an explicit resumable state-machine step?

Two contracts seem coherent:

  1. Durable pending Session batch. Record the canonical pending batch and write frontier in RunState. Before any later model call, confirm an exact Session-tail match or append the missing atomic batch; clear the marker only after success, and fail closed on ambiguity. This preserves live and cross-process recovery without replaying the tool.
  2. Non-resumable after persistence failure. If reconciliation is intentionally outside the SDK contract, mark the state non-resumable and raise an actionable error before any later model call. A public recovery payload/helper would still be useful.

I would prefer the first contract, but I would like maintainer guidance on the transaction boundary before preparing an implementation. Simply rolling the state back to the interruption would risk replaying an irreversible tool, while _current_turn_persisted_item_count alone cannot identify the exact filtered batch or distinguish a committed-but-unacknowledged append.

Coordination with existing work

I searched current open and closed issues and did not find this failure mode. The closest work covers different boundaries:

Open PR #2695 touches run.py, run_loop.py, session_persistence.py, and run_state.py while adding durable approval argument overrides and partial-failure handling. Its exact head, 7bb9415c5b3f695b97f6409930b92e8a9f2b9e1e, still reproduces this issue, so this is not already fixed there. An implementation would overlap the same persistence machinery, so I would coordinate with #2695 rather than open a competing code change before the transaction contract is agreed.

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