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:
- The original Session exception should still propagate.
- The completed tool side effect must not execute again.
- The failed state must not remain silently resumable into another model call while the Session is missing the completed tool output.
- 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:
- The tool executes and produces its output.
RunState advances to next_step_run_again.
Session.add_items() raises before storing the output.
- The error propagates, but the advanced state remains reusable.
- Retrying from that state skips interruption resolution and goes directly to the next model call.
- No path reconstructs or retries the missing Session batch.
- 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:
- 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.
- 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.
Describe the bug
When an approved local tool has already executed but persisting its output to a client-managed
Sessionfails, theRunStateadvances tonext_step_run_againbefore 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_callthat has no matchingfunction_call_output.This creates two conflicting conversation views:
RunStateandRunResult.to_input_list()contain the completed call/output pair.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
v0.20.0,v0.21.0,v0.22.0, and currentmainatfe45b415ee05479725cd6fb20a51c0d5cd73b3c1ScriptedModel; no provider or API keyv0.20.0d2bda3f3110415bf02e526a3983b0d0fa903e0d7v0.21.025aa6d94a1b9048772893d480fb467c3177ccd66v0.22.04df9ecfae1761ca6fea67cc5a20b383c1d492024mainfe45b415ee05479725cd6fb20a51c0d5cd73b3c1For
v0.20.0, I used the repository's deterministictests.fake_model.FakeModel; later versions andmainusedagents.testing.ScriptedModel.Repro steps
Observed output on the pinned
mainhead:I also ran each phase independently through
Runner.run()orRunner.run_streamed(). Every combination produced one tool side effect,Sessioncall/output counts of1 / 0, replay counts of1 / 1, and final outputdone:runrunrunrunrunrun_streamedrunrun_streamedrunrunrun_streamedrun_streamedrun_streamedrunrunrun_streamedrunrun_streamedrun_streamedrun_streamedrunrun_streamedrun_streamedrun_streamedExpected behavior
After the first persistence failure:
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:
RunStateadvances tonext_step_run_again.Session.add_items()raises before storing the output.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 advanceRunState.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 theNextStepRunAgainSession write.After the exception, the state already says
NextStepRunAgain. A later resume no longer has the originalturn_session_items, andsave_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_itemstests/test_agent_runner_streamed.py::test_streaming_resume_persists_tool_outputs_on_run_againProposed 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
RunStateshould: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
RunStatefields 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:
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.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_countalone 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, andrun_state.pywhile 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.