From eaf8c303da4621ee61f9bce9699e937bf1ff8631 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 12:12:16 +0200 Subject: [PATCH 01/30] fix(sessions): persist deferred interrupted-turn items when the approval resume continues the run With output guardrails and a non-default tool_use_behavior, _should_defer_interrupted_session_items defers the interrupted turn's session items at interruption time. When the approval resume resolves into next_step_run_again (or a handoff), the resume-side write only carried the resolved turn's new items - the tool output - and no later write recovered the deferred function_call. The Session ended up with a function_call_output whose call was never persisted, and the Responses API rejects every later run over that Session with 'No tool call found for function call output'. Persist the deferred prefix (the current response's session items, located via the resumed response boundary) ahead of the resolved turn's items once the resume commits to continuing the run, in both the streamed and non-streamed paths. The final-output path is untouched: its persistence already reconstructs the full current response. A resume that interrupts again keeps deferring. --- src/agents/run.py | 24 +++- src/agents/run_internal/run_loop.py | 20 +++- ...test_deferred_interrupted_session_write.py | 108 ++++++++++++++++++ 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/test_deferred_interrupted_session_write.py diff --git a/src/agents/run.py b/src/agents/run.py index 297895a347..61964620a1 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1128,7 +1128,29 @@ def _mark_response_hooks_started() -> None: input_before_turn_rewrite = original_input original_input = turn_result.original_input + # Captured before ``update_run_state_after_resume`` replaces + # ``_session_items``: the park-time list still holds the + # current response's deferred items. + base_session_items = ( + list(run_state._session_items) if run_state is not None else [] + ) generated_items, turn_session_items = resumed_turn_items(turn_result) + # Mirror of the streamed path: when the interruption-time + # write was deferred (``_should_defer_interrupted_session_items``), + # persist that deferred prefix ahead of the resolved turn's + # items once the resume commits to continuing the run. + deferred_session_prefix: list[RunItem] = [] + if ( + run_state is not None + and _should_defer_interrupted_session_items( + current_agent, run_config + ) + and run_state._current_turn_persisted_item_count == 0 + and resumed_response_boundary.session_start is not None + ): + deferred_session_prefix = base_session_items[ + resumed_response_boundary.session_start : + ] session_items.extend(turn_session_items) if run_state is not None: if turn_result.nested_history_owned_items is not None: @@ -1179,7 +1201,7 @@ def _mark_response_hooks_started() -> None: await save_resumed_turn_items( run_state=run_state, session=session, - items=turn_session_items, + items=deferred_session_prefix + turn_session_items, persisted_count=( run_state._current_turn_persisted_item_count ), diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 9871a54041..bad75859df 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1341,6 +1341,22 @@ async def _save_max_turns_items( base_session_items = ( list(run_state._session_items) if run_state is not None else [] ) + # If the interruption-time write for this response was deferred + # (``_finalize_streamed_interruption`` persisted ``[]`` because + # ``_should_defer_interrupted_session_items`` was true), nothing of + # the current response is in the Session yet. Once the resume + # commits to continuing the run (run-again / handoff), persist that + # deferred prefix ahead of the resolved turn's items so the tool + # output never lands without its ``function_call``. + deferred_session_prefix: list[RunItem] = [] + if ( + _should_defer_interrupted_session_items(current_agent, run_config) + and streamed_result._current_turn_persisted_item_count == 0 + and resumed_response_boundary.session_start is not None + ): + deferred_session_prefix = base_session_items[ + resumed_response_boundary.session_start : + ] streamed_result._model_input_items = generated_items streamed_result.new_items = base_session_items + list(turn_session_items) if turn_result.nested_history_owned_items is not None: @@ -1429,7 +1445,7 @@ async def _save_max_turns_items( run_state._current_agent = current_agent _publish_streamed_result_agent(streamed_result, current_agent) await _save_resumed_items( - list(turn_session_items), + deferred_session_prefix + list(turn_session_items), turn_result.model_response.response_id, store_setting, ) @@ -1472,7 +1488,7 @@ async def _save_max_turns_items( if isinstance(turn_result.next_step, NextStepRunAgain): await _save_resumed_items( - list(turn_session_items), + deferred_session_prefix + list(turn_session_items), turn_result.model_response.response_id, store_setting, ) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py new file mode 100644 index 0000000000..09052b7fc0 --- /dev/null +++ b/tests/test_deferred_interrupted_session_write.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json + +import pytest + +from agents import ( + Agent, + GuardrailFunctionOutput, + RunContextWrapper, + Runner, + RunState, + StopAtTools, + function_tool, + output_guardrail, +) +from agents.agent import Agent as AgentType +from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call +from tests.utils.simple_session import SimpleListSession + + +@function_tool(name_override="write_thing", needs_approval=True) +def write_thing(query: str) -> str: + return f"wrote:{query}" + + +@function_tool(name_override="look_up", needs_approval=False) +def look_up(query: str) -> str: + return f"schema for {query}" + + +@output_guardrail +async def always_fine( + ctx: RunContextWrapper[object], agent: AgentType[object], output: object +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + +def make_agent() -> Agent: + return Agent( + name="deferred repro", + instructions="Always call write_thing.", + model=ScriptedModel( + [ + # Two model turns before the interruption, like a real agent: an + # ungated lookup first, THEN the gated write. The interruption must + # land on turn > 1 so the resumed boundary has an accepted prefix. + ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[look_up, write_thing], + # The two conditions that open ``_should_defer_interrupted_session_items``: + # output guardrails AND ``tool_use_behavior != "run_llm_again"``. The approved + # tool is NOT in the stop list, so the resume resolves into + # ``next_step_run_again`` rather than a terminal tool output. + output_guardrails=[always_fine], + tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]), + ) + + +@pytest.mark.asyncio +async def test_deferred_parked_call_is_persisted_when_the_resume_runs_again() -> None: + """An approved tool's ``function_call`` must reach the Session, not only its output. + + With output guardrails and a non-default ``tool_use_behavior``, the interrupted + turn's session items are deferred at interruption time + (``_should_defer_interrupted_session_items``). When the approval resume resolves + into ``next_step_run_again``, the resume-side write only carries the resolved + turn's new items (the tool output), and no later write recovers the deferred + ``function_call``. The Session ends up with a ``function_call_output`` whose call + was never persisted, and the Responses API rejects every later run over that + Session with "No tool call found for function call output". + """ + session = SimpleListSession() + agent = make_agent() + + first = Runner.run_streamed(agent, "do the thing", session=session) + async for _ in first.stream_events(): + pass + assert len(first.interruptions) == 1 + + # Park in an external store and resume from it, as a multi-process app must: + # the RunState round-trips through JSON between the two runs. + serialized = json.dumps(first.to_state().to_json()) + state = await RunState.from_json(agent, json.loads(serialized)) + state.approve(state.get_interruptions()[0]) + + resumed = Runner.run_streamed(agent, state, session=session) + async for _ in resumed.stream_events(): + pass + assert resumed.final_output == "done" + + items = await session.get_items() + call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} + orphaned = [ + item + for item in items + if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids + ] + assert orphaned == [], ( + "the approved tool's function_call never reached the Session; " + f"orphaned outputs: {[item.get('call_id') for item in orphaned]}" + ) + assert "call_PARKED" in call_ids From 9245717721a5e2b749496cbbc5ff26b681f4f29d Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 12:29:45 +0200 Subject: [PATCH 02/30] refactor: share the deferred-prefix selection between both resume paths Codex review: the selection lived twice, once per resume path, and AGENTS.md wants runtime logic under run_internal. It now lives next to the gate that governs it (_deferred_interrupted_session_prefix in blocked_output.py) and both paths call it. --- src/agents/run.py | 28 ++++++++++------------- src/agents/run_internal/blocked_output.py | 24 +++++++++++++++++++ src/agents/run_internal/run_loop.py | 24 +++++++------------ 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 61964620a1..3fc2630f08 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -88,6 +88,7 @@ _blocked_output_failure_items, _BlockedOutputOwnerStarts, _current_response_boundary, + _deferred_interrupted_session_prefix, _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, @@ -1135,22 +1136,17 @@ def _mark_response_hooks_started() -> None: list(run_state._session_items) if run_state is not None else [] ) generated_items, turn_session_items = resumed_turn_items(turn_result) - # Mirror of the streamed path: when the interruption-time - # write was deferred (``_should_defer_interrupted_session_items``), - # persist that deferred prefix ahead of the resolved turn's - # items once the resume commits to continuing the run. - deferred_session_prefix: list[RunItem] = [] - if ( - run_state is not None - and _should_defer_interrupted_session_items( - current_agent, run_config - ) - and run_state._current_turn_persisted_item_count == 0 - and resumed_response_boundary.session_start is not None - ): - deferred_session_prefix = base_session_items[ - resumed_response_boundary.session_start : - ] + deferred_session_prefix = _deferred_interrupted_session_prefix( + current_agent, + run_config, + base_session_items=base_session_items, + persisted_count=( + run_state._current_turn_persisted_item_count + if run_state is not None + else 0 + ), + session_start=resumed_response_boundary.session_start, + ) session_items.extend(turn_session_items) if run_state is not None: if turn_result.nested_history_owned_items is not None: diff --git a/src/agents/run_internal/blocked_output.py b/src/agents/run_internal/blocked_output.py index 667871f331..a8cdde4fa6 100644 --- a/src/agents/run_internal/blocked_output.py +++ b/src/agents/run_internal/blocked_output.py @@ -320,6 +320,30 @@ def _should_defer_interrupted_session_items( return _has_output_guardrails(agent, run_config) and agent.tool_use_behavior != "run_llm_again" +def _deferred_interrupted_session_prefix( + agent: Agent[Any], + run_config: RunConfig, + *, + base_session_items: Sequence[RunItem], + persisted_count: int, + session_start: int | None, +) -> list[RunItem]: + """Return the interrupted response's session items whose write was deferred. + + When ``_should_defer_interrupted_session_items`` gated the interruption-time write, + nothing of the current response reached the Session. Once a resume commits to + continuing the run, this prefix must be persisted ahead of the resolved turn's items + so a tool output never lands without its ``function_call``. Empty whenever the + interruption-time write actually ran, something of the current turn is already + persisted, or the resumed response boundary could not locate the session start. + """ + if not _should_defer_interrupted_session_items(agent, run_config): + return [] + if persisted_count != 0 or session_start is None: + return [] + return list(base_session_items[session_start:]) + + def _validate_resumed_session_output_guardrail_safety( *, agent: Agent[Any], diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index bad75859df..2c25ddfa44 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -112,6 +112,7 @@ OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, _BlockedOutputOwnerStarts, _current_response_boundary, + _deferred_interrupted_session_prefix, _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, @@ -1341,22 +1342,13 @@ async def _save_max_turns_items( base_session_items = ( list(run_state._session_items) if run_state is not None else [] ) - # If the interruption-time write for this response was deferred - # (``_finalize_streamed_interruption`` persisted ``[]`` because - # ``_should_defer_interrupted_session_items`` was true), nothing of - # the current response is in the Session yet. Once the resume - # commits to continuing the run (run-again / handoff), persist that - # deferred prefix ahead of the resolved turn's items so the tool - # output never lands without its ``function_call``. - deferred_session_prefix: list[RunItem] = [] - if ( - _should_defer_interrupted_session_items(current_agent, run_config) - and streamed_result._current_turn_persisted_item_count == 0 - and resumed_response_boundary.session_start is not None - ): - deferred_session_prefix = base_session_items[ - resumed_response_boundary.session_start : - ] + deferred_session_prefix = _deferred_interrupted_session_prefix( + current_agent, + run_config, + base_session_items=base_session_items, + persisted_count=streamed_result._current_turn_persisted_item_count, + session_start=resumed_response_boundary.session_start, + ) streamed_result._model_input_items = generated_items streamed_result.new_items = base_session_items + list(turn_session_items) if turn_result.nested_history_owned_items is not None: From 6c3174c81847defd49577603cbd471cae4a157c2 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 12:48:03 +0200 Subject: [PATCH 03/30] fix: derive the park-time deferral decision from checkpoint state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: re-evaluating _should_defer_interrupted_session_items against the live configuration at resume time loses the deferred function_call again when the caller resumes with tool_use_behavior='run_llm_again' (reproduced before changing anything). A non-deferred interruption write bumps _current_turn_persisted_item_count, so persisted_count == 0 identifies the deferred park on its own — the helper now keys on checkpoint state only, which also keeps the prefix empty (no double write) when the interruption-time write actually ran. Two tests: the behavior-change resume, and the non-deferred park not being written twice (mutation-checked: dropping the persisted-count guard turns it red). --- src/agents/run.py | 2 - src/agents/run_internal/blocked_output.py | 16 ++-- src/agents/run_internal/run_loop.py | 2 - ...test_deferred_interrupted_session_write.py | 83 ++++++++++++++++++- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 3fc2630f08..a269d0c69c 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1137,8 +1137,6 @@ def _mark_response_hooks_started() -> None: ) generated_items, turn_session_items = resumed_turn_items(turn_result) deferred_session_prefix = _deferred_interrupted_session_prefix( - current_agent, - run_config, base_session_items=base_session_items, persisted_count=( run_state._current_turn_persisted_item_count diff --git a/src/agents/run_internal/blocked_output.py b/src/agents/run_internal/blocked_output.py index a8cdde4fa6..4fe2dc0569 100644 --- a/src/agents/run_internal/blocked_output.py +++ b/src/agents/run_internal/blocked_output.py @@ -321,8 +321,6 @@ def _should_defer_interrupted_session_items( def _deferred_interrupted_session_prefix( - agent: Agent[Any], - run_config: RunConfig, *, base_session_items: Sequence[RunItem], persisted_count: int, @@ -333,12 +331,16 @@ def _deferred_interrupted_session_prefix( When ``_should_defer_interrupted_session_items`` gated the interruption-time write, nothing of the current response reached the Session. Once a resume commits to continuing the run, this prefix must be persisted ahead of the resolved turn's items - so a tool output never lands without its ``function_call``. Empty whenever the - interruption-time write actually ran, something of the current turn is already - persisted, or the resumed response boundary could not locate the session start. + so a tool output never lands without its ``function_call``. + + The park-time decision is derived from checkpoint state, never re-evaluated against + the live configuration: the caller may resume with a different ``tool_use_behavior`` + or guardrail set, and consulting today's gate would drop the deferred items when the + gate has since closed. A non-deferred interruption write bumps + ``_current_turn_persisted_item_count``, so ``persisted_count == 0`` identifies the + deferred park on its own — and also keeps this empty (no double write) when the + interruption-time write actually ran, whatever the configuration says now. """ - if not _should_defer_interrupted_session_items(agent, run_config): - return [] if persisted_count != 0 or session_start is None: return [] return list(base_session_items[session_start:]) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 2c25ddfa44..60d06d0335 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1343,8 +1343,6 @@ async def _save_max_turns_items( list(run_state._session_items) if run_state is not None else [] ) deferred_session_prefix = _deferred_interrupted_session_prefix( - current_agent, - run_config, base_session_items=base_session_items, persisted_count=streamed_result._current_turn_persisted_item_count, session_start=resumed_response_boundary.session_start, diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 09052b7fc0..e9d0789f2e 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import Literal import pytest @@ -36,7 +37,12 @@ async def always_fine( return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) -def make_agent() -> Agent: +DEFERRING_BEHAVIOR = StopAtTools(stop_at_tool_names=["finish"]) + + +def make_agent( + tool_use_behavior: StopAtTools | Literal["run_llm_again"] = DEFERRING_BEHAVIOR, +) -> Agent: return Agent( name="deferred repro", instructions="Always call write_thing.", @@ -58,7 +64,7 @@ def make_agent() -> Agent: # tool is NOT in the stop list, so the resume resolves into # ``next_step_run_again`` rather than a terminal tool output. output_guardrails=[always_fine], - tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]), + tool_use_behavior=tool_use_behavior, ) @@ -106,3 +112,76 @@ async def test_deferred_parked_call_is_persisted_when_the_resume_runs_again() -> f"orphaned outputs: {[item.get('call_id') for item in orphaned]}" ) assert "call_PARKED" in call_ids + + +@pytest.mark.asyncio +async def test_park_time_deferral_survives_a_tool_use_behavior_change_on_resume() -> None: + """The deferral decision is the checkpoint's, not the resuming configuration's. + + Parking defers the interrupted turn's write (guardrails + non-default + ``tool_use_behavior``); the caller then resumes with ``"run_llm_again"``. Deriving + the decision from today's gate would conclude nothing was deferred and drop the + parked ``function_call`` again — it must come from checkpoint state instead + (``_current_turn_persisted_item_count``). + """ + session = SimpleListSession() + + first = Runner.run_streamed(make_agent(), "do the thing", session=session) + async for _ in first.stream_events(): + pass + assert len(first.interruptions) == 1 + + resume_agent = make_agent(tool_use_behavior="run_llm_again") + serialized = json.dumps(first.to_state().to_json()) + state = await RunState.from_json(resume_agent, json.loads(serialized)) + state.approve(state.get_interruptions()[0]) + + resumed = Runner.run_streamed(resume_agent, state, session=session) + async for _ in resumed.stream_events(): + pass + + items = await session.get_items() + call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} + orphaned = [ + item + for item in items + if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids + ] + assert orphaned == [] + assert "call_PARKED" in call_ids + + +@pytest.mark.asyncio +async def test_non_deferred_park_is_not_double_written_on_resume() -> None: + """The other direction of deriving from state: with ``"run_llm_again"`` throughout, + the interruption-time write runs (no deferral) and bumps the persisted count, so the + resume must not write the parked ``function_call`` a second time.""" + session = SimpleListSession() + agent = make_agent(tool_use_behavior="run_llm_again") + + first = Runner.run_streamed(agent, "do the thing", session=session) + async for _ in first.stream_events(): + pass + assert len(first.interruptions) == 1 + + serialized = json.dumps(first.to_state().to_json()) + state = await RunState.from_json(agent, json.loads(serialized)) + state.approve(state.get_interruptions()[0]) + + resumed = Runner.run_streamed(agent, state, session=session) + async for _ in resumed.stream_events(): + pass + + items = await session.get_items() + parked_calls = [ + item + for item in items + if item.get("type") == "function_call" and item.get("call_id") == "call_PARKED" + ] + parked_outputs = [ + item + for item in items + if item.get("type") == "function_call_output" and item.get("call_id") == "call_PARKED" + ] + assert len(parked_calls) == 1 + assert len(parked_outputs) == 1 From 449e91ae273cc4aee6fe1e0914b52f651beb0ae0 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 13:03:48 +0200 Subject: [PATCH 04/30] fix: carry the deferred prefix through a streamed re-interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: a resume can interrupt again (partial approval of a multi-approval response). If the gate no longer defers, that re-interruption write is the deferred prefix's last chance — it bumps the persisted count, so writing only the approved tool's output there orphaned BOTH parked calls for every later resume (reproduced: 2 orphans before this change). The streamed re-interruption branch now prepends the prefix exactly as the non-streaming path already did; a gate that still defers keeps deferring, and the still-deferring variant recovers everything at final output (verified). Regression test proven red against the previous commit. --- src/agents/run_internal/run_loop.py | 8 +- ...test_deferred_interrupted_session_write.py | 84 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 60d06d0335..6d5c131384 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1405,13 +1405,19 @@ async def _save_max_turns_items( await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_resumed_items, + # A resume can interrupt again (a partial approval of a + # multi-approval response). If the gate still defers, keep + # deferring; otherwise this write is the deferred prefix's + # last chance — it bumps the persisted count, so leaving the + # prefix out here would orphan the parked calls for every + # later resume. Mirrors the non-streaming path's guard. items=( [] if _should_defer_interrupted_session_items( current_agent, run_config, ) - else list(turn_session_items) + else deferred_session_prefix + list(turn_session_items) ), response_id=turn_result.model_response.response_id, store_setting=store_setting, diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index e9d0789f2e..6c0b336e84 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -185,3 +185,87 @@ async def test_non_deferred_park_is_not_double_written_on_resume() -> None: ] assert len(parked_calls) == 1 assert len(parked_outputs) == 1 + + +@function_tool(name_override="write_other", needs_approval=True) +def write_other(query: str) -> str: + return f"other:{query}" + + +def make_multi_approval_agent( + tool_use_behavior: StopAtTools | Literal["run_llm_again"] = DEFERRING_BEHAVIOR, +) -> Agent: + """One deferred model response carrying TWO approval-required calls.""" + return Agent( + name="deferred repro (multi)", + instructions="Call both tools.", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("write_other", {"query": "x"}, call_id="call_PARKED_2"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing, write_other], + output_guardrails=[always_fine], + tool_use_behavior=tool_use_behavior, + ) + + +@pytest.mark.asyncio +async def test_partial_approval_reinterruption_persists_the_deferred_prefix() -> None: + """A resume that interrupts AGAIN must not strand the deferred calls. + + Two approval-required calls in one deferred response; the caller approves only one + and resumes with ``"run_llm_again"`` (gate closed). The resume resolves back into + ``NextStepInterruption``, and that re-interruption write is the deferred prefix's + last chance: it bumps the persisted count, so writing only the approved tool's + output there would orphan BOTH parked calls for every later resume. + """ + session = SimpleListSession() + + first = Runner.run_streamed(make_multi_approval_agent(), "go", session=session) + async for _ in first.stream_events(): + pass + assert len(first.interruptions) == 2 + + resume_agent = make_multi_approval_agent(tool_use_behavior="run_llm_again") + serialized = json.dumps(first.to_state().to_json()) + state = await RunState.from_json(resume_agent, json.loads(serialized)) + first_approval = next( + interruption + for interruption in state.get_interruptions() + if "call_PARKED" == getattr(interruption.raw_item, "call_id", None) + ) + state.approve(first_approval) + + second = Runner.run_streamed(resume_agent, state, session=session) + async for _ in second.stream_events(): + pass + assert len(second.interruptions) == 1 + + serialized = json.dumps(second.to_state().to_json()) + state = await RunState.from_json(resume_agent, json.loads(serialized)) + for interruption in state.get_interruptions(): + state.approve(interruption) + final = Runner.run_streamed(resume_agent, state, session=session) + async for _ in final.stream_events(): + pass + assert final.final_output == "done" + + items = await session.get_items() + call_ids = [item.get("call_id") for item in items if item.get("type") == "function_call"] + orphaned = [ + item + for item in items + if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids + ] + assert orphaned == [] + # Each parked call exactly once: recovered by the re-interruption write, and not + # written again by the later resumes (the persisted count now covers it). + assert call_ids.count("call_PARKED") == 1 + assert call_ids.count("call_PARKED_2") == 1 From 313b8c0e914e83267d06632a7f1b497d33817f51 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 14:49:04 +0200 Subject: [PATCH 05/30] fix: confirm the deferred prefix against the Session and carry it to every resume exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the previous commits, each reproduced before changing anything: 1. A resume whose approved tool IS terminal ends in final output, and _final_turn_items_for_persistence only rebuilds the current response when the agent has output guardrails — a resume may legitimately run without the ones the park had, and the parked function_call was dropped again (both runners). The prefix now rides that exit too. 2. persisted_count can legitimately lie: the resumed-safety validator resets it to zero for a DETACHED resume, and that reset outlives the run, so a later resume reconnecting the original Session rewrote items it already held (duplicate function_calls, measured). The prefix is now CONFIRMED against the Session's own tail using the existing fingerprint helpers, so the write is idempotent by construction and a detached resume degrades to writing nothing. 3. An empty resolved turn (a handoff input_filter can drop every item) must not strand the prefix on its own: a call written without its output poisons the Session exactly as the orphaned output does. It keeps deferring instead, and both runners now agree on that. The helper moves to session_persistence, where the Session read and the fingerprint helpers already live, and becomes async. Five new tests, the three new ones proven red against the previous commit. --- src/agents/run.py | 28 ++++- src/agents/run_internal/blocked_output.py | 26 ---- src/agents/run_internal/run_loop.py | 33 ++++- .../run_internal/session_persistence.py | 52 ++++++++ ...test_deferred_interrupted_session_write.py | 116 ++++++++++++++++++ 5 files changed, 221 insertions(+), 34 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index a269d0c69c..a34e6e27fb 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -88,7 +88,6 @@ _blocked_output_failure_items, _BlockedOutputOwnerStarts, _current_response_boundary, - _deferred_interrupted_session_prefix, _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, @@ -141,6 +140,7 @@ _session_get_items, admit_pending_input, commit_server_pending_input, + deferred_interrupted_session_prefix, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -976,6 +976,10 @@ def _mark_response_hooks_started() -> None: current_task_span.finish(reset_current=True) raise + # The deferred prefix belongs to the resumed turn only: it is filled when that turn + # locates it and emptied once written, so later turns of the same run never + # re-send it. + deferred_session_prefix: list[RunItem] = [] try: while True: validate_output_guardrails_with_server_managed_conversation( @@ -1136,7 +1140,8 @@ def _mark_response_hooks_started() -> None: list(run_state._session_items) if run_state is not None else [] ) generated_items, turn_session_items = resumed_turn_items(turn_result) - deferred_session_prefix = _deferred_interrupted_session_prefix( + deferred_session_prefix = await deferred_interrupted_session_prefix( + session, base_session_items=base_session_items, persisted_count=( run_state._current_turn_persisted_item_count @@ -1144,6 +1149,12 @@ def _mark_response_hooks_started() -> None: else 0 ), session_start=resumed_response_boundary.session_start, + reasoning_item_id_policy=( + run_state._reasoning_item_id_policy + if run_state is not None + else None + ), + wrapper=context_wrapper, ) session_items.extend(turn_session_items) if run_state is not None: @@ -1182,6 +1193,9 @@ def _mark_response_hooks_started() -> None: session_persistence_enabled and turn_session_items and run_state is not None + # A final output is persisted by the final-turn sweep + # below, which receives the prefix through + # ``final_turn_deferred_prefix``. and not isinstance(turn_result.next_step, NextStepFinalOutput) and not ( isinstance(turn_result.next_step, NextStepInterruption) @@ -1260,6 +1274,9 @@ def _mark_response_hooks_started() -> None: return _finalize_result(result) if isinstance(turn_result.next_step, NextStepRunAgain): + # Written above with the resolved turn's items; later turns + # of this run must not re-send it. + deferred_session_prefix = [] continue append_model_response_if_new( @@ -1389,7 +1406,12 @@ def _mark_response_hooks_started() -> None: raise final_turn_items = _final_turn_items_for_persistence( - turn_session_items, + # Same reason as the streamed path: with output + # guardrails this rebuilds the whole current response + # and would recover the deferred prefix, but WITHOUT + # them it returns these items verbatim — and a resume + # may run without the guardrails the park had. + deferred_session_prefix + list(turn_session_items), current_processed_response, run_state, current_agent, diff --git a/src/agents/run_internal/blocked_output.py b/src/agents/run_internal/blocked_output.py index 4fe2dc0569..667871f331 100644 --- a/src/agents/run_internal/blocked_output.py +++ b/src/agents/run_internal/blocked_output.py @@ -320,32 +320,6 @@ def _should_defer_interrupted_session_items( return _has_output_guardrails(agent, run_config) and agent.tool_use_behavior != "run_llm_again" -def _deferred_interrupted_session_prefix( - *, - base_session_items: Sequence[RunItem], - persisted_count: int, - session_start: int | None, -) -> list[RunItem]: - """Return the interrupted response's session items whose write was deferred. - - When ``_should_defer_interrupted_session_items`` gated the interruption-time write, - nothing of the current response reached the Session. Once a resume commits to - continuing the run, this prefix must be persisted ahead of the resolved turn's items - so a tool output never lands without its ``function_call``. - - The park-time decision is derived from checkpoint state, never re-evaluated against - the live configuration: the caller may resume with a different ``tool_use_behavior`` - or guardrail set, and consulting today's gate would drop the deferred items when the - gate has since closed. A non-deferred interruption write bumps - ``_current_turn_persisted_item_count``, so ``persisted_count == 0`` identifies the - deferred park on its own — and also keeps this empty (no double write) when the - interruption-time write actually ran, whatever the configuration says now. - """ - if persisted_count != 0 or session_start is None: - return [] - return list(base_session_items[session_start:]) - - def _validate_resumed_session_output_guardrail_safety( *, agent: Agent[Any], diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 6d5c131384..1fd5b75553 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -112,7 +112,6 @@ OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT, _BlockedOutputOwnerStarts, _current_response_boundary, - _deferred_interrupted_session_prefix, _final_turn_items_for_persistence, _has_output_guardrails, _is_terminal_tool_output_response, @@ -176,6 +175,7 @@ _session_get_items, admit_pending_input, commit_server_pending_input, + deferred_interrupted_session_prefix, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -1200,6 +1200,10 @@ async def _save_max_turns_items( raise try: + # The deferred prefix belongs to the resumed turn only: it is filled when that turn + # locates it and emptied once written, so later turns of the same run never + # re-send it. + deferred_session_prefix: list[RunItem] = [] while True: validate_output_guardrails_with_server_managed_conversation( current_agent, @@ -1342,10 +1346,12 @@ async def _save_max_turns_items( base_session_items = ( list(run_state._session_items) if run_state is not None else [] ) - deferred_session_prefix = _deferred_interrupted_session_prefix( + deferred_session_prefix = await deferred_interrupted_session_prefix( + session, base_session_items=base_session_items, persisted_count=streamed_result._current_turn_persisted_item_count, session_start=resumed_response_boundary.session_start, + reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, ) streamed_result._model_input_items = generated_items streamed_result.new_items = base_session_items + list(turn_session_items) @@ -1440,8 +1446,14 @@ async def _save_max_turns_items( if run_state is not None: run_state._current_agent = current_agent _publish_streamed_result_agent(streamed_result, current_agent) + # An empty resolved turn (a handoff input_filter can drop + # every item) must not leave the prefix stranded on its own: + # a call written without its output poisons the Session just + # as the orphaned output does. Keep deferring instead. await _save_resumed_items( - deferred_session_prefix + list(turn_session_items), + (deferred_session_prefix + list(turn_session_items)) + if turn_session_items + else [], turn_result.model_response.response_id, store_setting, ) @@ -1466,7 +1478,12 @@ async def _save_max_turns_items( output=turn_result.next_step.output, context_wrapper=context_wrapper, save_items=_save_resumed_items, - items=list(turn_session_items), + # The deferred prefix rides here too: with output guardrails + # ``_final_turn_items_for_persistence`` rebuilds the whole + # current response and would recover it, but WITHOUT them it + # returns these items verbatim — and a resume may legitimately + # run without the guardrails the park had. + items=deferred_session_prefix + list(turn_session_items), model_response=turn_result.model_response, processed_response=( turn_result.processed_response @@ -1483,8 +1500,14 @@ async def _save_max_turns_items( break if isinstance(turn_result.next_step, NextStepRunAgain): + # An empty resolved turn (a handoff input_filter can drop + # every item) must not leave the prefix stranded on its own: + # a call written without its output poisons the Session just + # as the orphaned output does. Keep deferring instead. await _save_resumed_items( - deferred_session_prefix + list(turn_session_items), + (deferred_session_prefix + list(turn_session_items)) + if turn_session_items + else [], turn_result.model_response.response_id, store_setting, ) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index f8d4f83b3c..8a329adbfb 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -80,6 +80,7 @@ "resumed_turn_items", "save_result_to_session", "save_resumed_turn_items", + "deferred_interrupted_session_prefix", "resume_pending_session_write", "update_run_state_after_resume", "rewind_session_items", @@ -794,6 +795,57 @@ async def save_resumed_turn_items( return persisted_count + saved_count +async def deferred_interrupted_session_prefix( + session: Session | None, + *, + base_session_items: Sequence[RunItem], + persisted_count: int, + session_start: int | None, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + wrapper: RunContextWrapper[Any] | None = None, +) -> list[RunItem]: + """Return the interrupted response's session items that are still missing from the Session. + + When ``_should_defer_interrupted_session_items`` gated the interruption-time write, + nothing of the current response reached the Session, and a resume that continues the + run must persist that prefix ahead of the resolved turn's items — otherwise a tool + output lands without its ``function_call`` and the provider rejects every later run + over the Session. + + The park-time decision is NOT re-evaluated against the live configuration: the caller + may resume with a different ``tool_use_behavior`` or guardrail set. It is read from + checkpoint state (``persisted_count``) and then CONFIRMED against the Session itself, + because that counter can legitimately lie: the resumed-safety validator resets it to + zero for a detached resume, and a later resume that reconnects the original Session + would otherwise rewrite items it already holds. Items already present + in the Session's tail are dropped here, so this is idempotent by construction and the + no-session case degrades to "write nothing", never to a duplicate. + """ + if persisted_count != 0 or session_start is None: + return [] + prefix = list(base_session_items[session_start:]) + if not prefix or session is None: + return prefix + # Pair each run item with the input item it would be written as, so an item already + # in the Session can be recognized. Items that convert to nothing (approvals) are + # never persisted, so they cannot be duplicates and ride along untouched. + paired = [(item, run_item_to_input_item(item, reasoning_item_id_policy)) for item in prefix] + candidates = [written for _, written in paired if written is not None] + if not candidates: + return prefix + ignore_ids = _ignore_ids_for_matching(session) + tail = await _session_get_items(session, limit=len(candidates), wrapper=wrapper) + if not tail: + return prefix + present = {_fingerprint_or_repr(item, ignore_ids_for_matching=ignore_ids) for item in tail} + return [ + item + for item, written in paired + if written is None + or _fingerprint_or_repr(written, ignore_ids_for_matching=ignore_ids) not in present + ] + + async def resume_pending_session_write( run_state: RunState, session: Session | None, diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 6c0b336e84..f754acb0d7 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -269,3 +269,119 @@ async def test_partial_approval_reinterruption_persists_the_deferred_prefix() -> # written again by the later resumes (the persisted count now covers it). assert call_ids.count("call_PARKED") == 1 assert call_ids.count("call_PARKED_2") == 1 + + +def make_terminal_tool_agent(with_guardrails: bool = True) -> Agent: + """The approved tool IS terminal, so the resume ends in a final output.""" + return Agent( + name="deferred repro (terminal)", + instructions="Always call write_thing.", + model=ScriptedModel( + [ + ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + ), + ] + ), + tools=[look_up, write_thing], + output_guardrails=[always_fine] if with_guardrails else [], + tool_use_behavior=StopAtTools(stop_at_tool_names=["write_thing"]), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("resume_with_guardrails", [True, False]) +@pytest.mark.parametrize("streamed", [True, False]) +async def test_deferred_prefix_reaches_a_resume_that_ends_in_final_output( + resume_with_guardrails: bool, streamed: bool +) -> None: + """The final-output exit needs the prefix too, in both runners. + + ``_final_turn_items_for_persistence`` rebuilds the whole current response ONLY when + the agent has output guardrails; without them it returns the turn's items verbatim. + A resume may legitimately run without the guardrails the park had, and then the + deferred ``function_call`` was dropped on this exit. + """ + session = SimpleListSession() + + async def go(agent: Agent, run_input: object) -> object: + if streamed: + result = Runner.run_streamed(agent, run_input, session=session) # type: ignore[arg-type] + async for _ in result.stream_events(): + pass + return result + return await Runner.run(agent, run_input, session=session) # type: ignore[arg-type] + + first = await go(make_terminal_tool_agent(), "do the thing") + assert len(first.interruptions) == 1 # type: ignore[attr-defined] + + resume_agent = make_terminal_tool_agent(with_guardrails=resume_with_guardrails) + serialized = json.dumps(first.to_state().to_json()) # type: ignore[attr-defined] + state = await RunState.from_json(resume_agent, json.loads(serialized)) + state.approve(state.get_interruptions()[0]) + await go(resume_agent, state) + + items = await session.get_items() + call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} + orphaned = [ + item + for item in items + if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids + ] + assert orphaned == [] + assert "call_PARKED" in call_ids + + +@pytest.mark.asyncio +async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session() -> None: + """``persisted_count`` can lie, so the prefix is confirmed against the Session. + + ``_validate_resumed_session_output_guardrail_safety`` resets the counter to zero for a + DETACHED resume ("a detached Session cannot contribute its old persisted prefix"). + That reset outlives the run, so a later resume reconnecting the original Session sees + zero and would rewrite items the Session already holds. + """ + session = SimpleListSession() + + parked = Runner.run_streamed( + make_multi_approval_agent(tool_use_behavior="run_llm_again"), "go", session=session + ) + async for _ in parked.stream_events(): + pass + assert len(parked.interruptions) == 2 + # No deferral at park time: the interrupted turn's items ARE persisted. + persisted_at_park = [item.get("call_id") for item in await session.get_items()] + assert "call_PARKED" in persisted_at_park + + deferring_agent = make_multi_approval_agent() + state = await RunState.from_json( + deferring_agent, json.loads(json.dumps(parked.to_state().to_json())) + ) + state.approve( + next( + interruption + for interruption in state.get_interruptions() + if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" + ) + ) + detached = Runner.run_streamed(deferring_agent, state, session=None) + async for _ in detached.stream_events(): + pass + + state = await RunState.from_json( + deferring_agent, json.loads(json.dumps(detached.to_state().to_json())) + ) + for interruption in state.get_interruptions(): + state.approve(interruption) + reconnected = Runner.run_streamed(deferring_agent, state, session=session) + async for _ in reconnected.stream_events(): + pass + + call_ids = [ + item.get("call_id") + for item in await session.get_items() + if item.get("type") == "function_call" + ] + assert call_ids.count("call_PARKED") == 1 + assert call_ids.count("call_PARKED_2") == 1 From 3b3cd8bca367d13e034b5afbae0796874c4e01e3 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 15:39:06 +0200 Subject: [PATCH 06/30] fix: suppress only what the Session provably holds, by collision-free identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: filtering the prefix against an unordered set of content fingerprints drops an item that merely LOOKS like one already there. An assistant preamble repeats verbatim across turns, so a tail holding an identical preamble from an EARLIER turn made the current one vanish while its calls were still appended — a legitimate occurrence lost from history (reproduced before changing anything). Matching the whole prefix as an ordered block was the obvious answer and is wrong too: a partially written response (calls persisted by an earlier attempt, output not yet) then matches nothing and duplicates the calls. Measured, both ways. So suppression is now keyed on identity that cannot collide — (type, call_id), unique per turn — and anything without one is kept unconditionally. A partially written response contributes exactly its missing half; nothing is ever dropped for looking familiar. Regression test proven red against the previous commit. --- .../run_internal/session_persistence.py | 58 ++++++++++++----- ...test_deferred_interrupted_session_write.py | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 8a329adbfb..4bffd9ca8d 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -69,6 +69,10 @@ SingleStepResult, ) +# How far past the prefix's own length to look for it: enough to clear the outputs a +# previous write of the same response would have appended after it. +_PREFIX_MATCH_LOOKBACK = 8 + __all__ = [ "admit_pending_input", "commit_server_pending_input", @@ -817,33 +821,51 @@ async def deferred_interrupted_session_prefix( checkpoint state (``persisted_count``) and then CONFIRMED against the Session itself, because that counter can legitimately lie: the resumed-safety validator resets it to zero for a detached resume, and a later resume that reconnects the original Session - would otherwise rewrite items it already holds. Items already present - in the Session's tail are dropped here, so this is idempotent by construction and the - no-session case degrades to "write nothing", never to a duplicate. + would otherwise rewrite items it already holds. The confirmation matches the WHOLE + converted prefix as a contiguous run inside the Session's tail, all or nothing: an + assistant preamble repeats verbatim across turns, so filtering item by item against an + unordered set would delete a legitimate occurrence from history while still appending + the calls around it. Either this exact response is already there (write nothing) or + none of it is (write all of it), which makes the write idempotent by construction. """ if persisted_count != 0 or session_start is None: return [] prefix = list(base_session_items[session_start:]) if not prefix or session is None: return prefix - # Pair each run item with the input item it would be written as, so an item already - # in the Session can be recognized. Items that convert to nothing (approvals) are - # never persisted, so they cannot be duplicates and ride along untouched. + # Suppress only what the Session provably already holds, and only by an identity that + # cannot collide: a tool call and a tool output are keyed by ``(type, call_id)``, which + # is unique per turn. Everything else — an assistant preamble, a reasoning item — is + # kept unconditionally, because those repeat verbatim across turns and a coincidental + # match would delete a legitimate occurrence from history. So a partially written + # response (its calls persisted by an earlier attempt, its output not yet) contributes + # exactly the missing half instead of duplicating or losing anything. paired = [(item, run_item_to_input_item(item, reasoning_item_id_policy)) for item in prefix] - candidates = [written for _, written in paired if written is not None] - if not candidates: + keyed = [key for _, written in paired if (key := _identity_key(written)) is not None] + if not keyed: return prefix - ignore_ids = _ignore_ids_for_matching(session) - tail = await _session_get_items(session, limit=len(candidates), wrapper=wrapper) - if not tail: + tail = await _session_get_items( + session, limit=len(keyed) * 2 + _PREFIX_MATCH_LOOKBACK, wrapper=wrapper + ) + present = {key for item in tail if (key := _identity_key(item)) is not None} + if not present: return prefix - present = {_fingerprint_or_repr(item, ignore_ids_for_matching=ignore_ids) for item in tail} - return [ - item - for item, written in paired - if written is None - or _fingerprint_or_repr(written, ignore_ids_for_matching=ignore_ids) not in present - ] + return [item for item, written in paired if _identity_key(written) not in present] + + +def _identity_key(item: TResponseInputItem | None) -> tuple[str, str] | None: + """Return a collision-free identity for an item, or ``None`` when it has none. + + Only ``call_id``-bearing items have one. Two assistant messages with the same text are + indistinguishable and must never be treated as the same row. + """ + if not isinstance(item, dict): + return None + call_id = item.get("call_id") + item_type = item.get("type") + if isinstance(call_id, str) and isinstance(item_type, str): + return (item_type, call_id) + return None async def resume_pending_session_write( diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index f754acb0d7..f72e7675ad 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -4,6 +4,11 @@ from typing import Literal import pytest +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, +) from agents import ( Agent, @@ -385,3 +390,63 @@ async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session( ] assert call_ids.count("call_PARKED") == 1 assert call_ids.count("call_PARKED_2") == 1 + + +@pytest.mark.asyncio +async def test_an_item_that_merely_looks_familiar_is_never_dropped() -> None: + """Only ``call_id``-keyed items can be recognized as already written. + + An assistant preamble repeats verbatim across turns, so a Session tail can hold an + identical one from an EARLIER turn while none of the current response is saved. + Suppressing by content would delete a legitimate occurrence from history while still + appending the calls around it, so items without a collision-free identity are always + kept. + """ + from agents.items import MessageOutputItem, ToolCallItem + from agents.run_internal.session_persistence import deferred_interrupted_session_prefix + + agent = Agent(name="preamble") + text = "Let me check that." + preamble = MessageOutputItem( + agent=agent, + raw_item=ResponseOutputMessage( + id="__fake_id__", + content=[ResponseOutputText(text=text, annotations=[], type="output_text")], + role="assistant", + status="completed", + type="message", + ), + ) + call = ToolCallItem( + agent=agent, + raw_item=ResponseFunctionToolCall( + id="__fake_id__", + call_id="call_NEW", + name="write_thing", + arguments="{}", + type="function_call", + ), + ) + # The Session already holds an identical preamble from a previous turn, and nothing + # of the current response. + session = SimpleListSession( + history=[ + {"role": "user", "content": "hi"}, + { + "id": "__fake_id__", + "content": [{"annotations": [], "text": text, "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + ] + ) + + kept = await deferred_interrupted_session_prefix( + session, + base_session_items=[preamble, call], + persisted_count=0, + session_start=0, + ) + + assert kept == [preamble, call] From b5b63f3522bfbbd30e61119b343ce3fbec1828cf Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 2 Sep 2026 16:38:16 +0200 Subject: [PATCH 07/30] fix: recognize hosted MCP approval identities when reconciling the prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: not every item family names its id 'call_id'. A hosted MCP approval request identifies itself with 'id' and its response points back with 'approval_request_id', so _identity_key returned None for both (measured) and a partially written response would append requests the Session already holds — duplicate request ids corrupt the history the next model call reads. The request identity is read through get_hosted_mcp_approval_request_identity, the repository's canonical helper, rather than a local rule. Request and response keep DISTINCT identities (same id, different type), so persisting one never suppresses the other. Items with no collision-free id still return None and are therefore never suppressed. Regression test proven red against the previous commit. --- .../run_internal/session_persistence.py | 29 +++++++++++---- ...test_deferred_interrupted_session_write.py | 35 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 4bffd9ca8d..ba2ab4d802 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -15,6 +15,7 @@ from typing import Any, cast from .. import _debug +from .._tool_identity import get_hosted_mcp_approval_request_identity from ..exceptions import UserError from ..items import ( HandoffOutputItem, @@ -856,16 +857,32 @@ async def deferred_interrupted_session_prefix( def _identity_key(item: TResponseInputItem | None) -> tuple[str, str] | None: """Return a collision-free identity for an item, or ``None`` when it has none. - Only ``call_id``-bearing items have one. Two assistant messages with the same text are - indistinguishable and must never be treated as the same row. + Only items carrying a unique per-turn id have one, and each family names it + differently: function calls and their outputs use ``call_id``, a hosted MCP approval + request uses ``id`` (read through the canonical + ``get_hosted_mcp_approval_request_identity`` rather than a local rule), and its + response points back with ``approval_request_id``. Everything else — an assistant + message, a reasoning item — returns ``None`` and is therefore never suppressed: two + with the same content are indistinguishable and must not be treated as the same row. """ if not isinstance(item, dict): return None - call_id = item.get("call_id") item_type = item.get("type") - if isinstance(call_id, str) and isinstance(item_type, str): - return (item_type, call_id) - return None + if not isinstance(item_type, str): + return None + if item_type == "mcp_approval_request": + identity = get_hosted_mcp_approval_request_identity(item) + request_id = identity.request_id if identity is not None else None + return (item_type, request_id) if request_id else None + if item_type == "mcp_approval_response": + approval_request_id = item.get("approval_request_id") + return ( + (item_type, approval_request_id) + if isinstance(approval_request_id, str) and approval_request_id + else None + ) + call_id = item.get("call_id") + return (item_type, call_id) if isinstance(call_id, str) and call_id else None async def resume_pending_session_write( diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index f72e7675ad..24240a9fdc 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -21,6 +21,7 @@ output_guardrail, ) from agents.agent import Agent as AgentType +from agents.items import TResponseInputItem from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call from tests.utils.simple_session import SimpleListSession @@ -450,3 +451,37 @@ async def test_an_item_that_merely_looks_familiar_is_never_dropped() -> None: ) assert kept == [preamble, call] + + +@pytest.mark.asyncio +async def test_hosted_mcp_approval_identities_are_recognized() -> None: + """Not every family names its id ``call_id``. + + A hosted MCP approval request identifies itself with ``id`` and its response points + back with ``approval_request_id``. Unrecognized, a partially written response would + append requests the Session already holds, and duplicate request ids corrupt the + history the next model call reads. + """ + from agents.run_internal.session_persistence import _identity_key + + request: TResponseInputItem = { + "type": "mcp_approval_request", + "id": "mcpr_123", + "name": "do_it", + "server_label": "srv", + "arguments": "{}", + } + response: TResponseInputItem = { + "type": "mcp_approval_response", + "approval_request_id": "mcpr_123", + "approve": True, + } + + assert _identity_key(request) == ("mcp_approval_request", "mcpr_123") + # The response is a DIFFERENT row than the request it answers: same id, distinct + # identity, so persisting the response never suppresses the request or vice versa. + assert _identity_key(response) == ("mcp_approval_response", "mcpr_123") + assert _identity_key(request) != _identity_key(response) + # Still nothing for a content-only item. + plain: TResponseInputItem = {"role": "assistant", "content": "hi", "type": "message"} + assert _identity_key(plain) is None From 47d3852bea26bcc171d6dad063542db30713013d Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Fri, 4 Sep 2026 14:57:51 +0200 Subject: [PATCH 08/30] test: pin that an emptied resolved turn corrupts nothing in either runner Review follow-up. The empty-turn shape (a handoff input_filter drops every item of the resolved turn) must not write the deferred prefix on its own: a call with no output poisons the Session exactly as the orphaned output does. Both runners must also agree item for item, because a divergence here is how a dangling-call regression first shows up. Proven red against 9ba9fefa, where the streamed path wrote both calls dangling (call_PARKED, call_HANDOFF) with no outputs. Also measured for the review discussion, not encoded in the test because they are properties of dead code: the RunAgain clear at run.py never executes in this shape (the emptied turn resolves into a handoff), RunAgain with empty session items is not constructible (approve and reject both yield an output item), and deleting the clear outright changes nothing across all reproducers and the full suite. The batch loss itself is real in BOTH runners and is the same root as the call-id collision finding: only the serialized checkpoint can carry it (#4827). --- ...test_deferred_interrupted_session_write.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 24240a9fdc..cc204c8cbf 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -15,6 +15,8 @@ GuardrailFunctionOutput, RunContextWrapper, Runner, + RunResult, + RunResultStreaming, RunState, StopAtTools, function_tool, @@ -485,3 +487,91 @@ async def test_hosted_mcp_approval_identities_are_recognized() -> None: # Still nothing for a content-only item. plain: TResponseInputItem = {"role": "assistant", "content": "hi", "type": "message"} assert _identity_key(plain) is None + + +def make_emptying_handoff_agent() -> Agent: + """One response carrying the gated call AND a handoff whose filter empties the turn. + + Resolving the approval then produces a turn with no session items at all: the shape + where a deferred prefix has nothing to ride on. + """ + from agents import HandoffInputData, handoff + + def empties(data: HandoffInputData) -> HandoffInputData: + return HandoffInputData( + input_history=data.input_history, pre_handoff_items=(), new_items=() + ) + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel( + [ + ModelStep(output=[assistant_message("done")]), + ModelStep(output=[assistant_message("done")]), + ] + ), + ) + return Agent( + name="deferred repro (emptied turn)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing], + handoffs=[handoff(target, input_filter=empties)], + output_guardrails=[always_fine], + tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]), + ) + + +@pytest.mark.asyncio +async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner() -> None: + """When the resolved turn has no session items, the deferred prefix must not be + written on its own: a call with no output poisons the Session exactly as the orphaned + output does. Both runners must also agree, item for item; a divergence here is how a + dangling-call regression would first show up. + """ + + async def run_case(streamed: bool) -> list[TResponseInputItem]: + session = SimpleListSession() + agent = make_emptying_handoff_agent() + first: RunResult | RunResultStreaming + if streamed: + first = Runner.run_streamed(agent, "go", session=session) + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(agent, "go", session=session) + assert len(first.interruptions) == 1 + serialized = json.dumps(first.to_state().to_json()) + state = await RunState.from_json(agent, json.loads(serialized)) + state.approve(state.get_interruptions()[0]) + if streamed: + resumed = Runner.run_streamed(agent, state, session=session) + async for _ in resumed.stream_events(): + pass + else: + await Runner.run(agent, state, session=session) + return await session.get_items() + + streamed_items = await run_case(streamed=True) + non_streamed_items = await run_case(streamed=False) + + for items in (streamed_items, non_streamed_items): + calls = {i.get("call_id") for i in items if i.get("type") == "function_call"} + outputs = {i.get("call_id") for i in items if i.get("type") == "function_call_output"} + assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" + + assert [(i.get("type") or i.get("role"), i.get("call_id")) for i in streamed_items] == [ + (i.get("type") or i.get("role"), i.get("call_id")) for i in non_streamed_items + ] From 0d485af0d869561bc9ca28ca54ae0d46b6e09870 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:27:46 +0200 Subject: [PATCH 09/30] fix(sessions): declare the withheld interrupted write as a held pending Session write The output-guardrail persistence gate withholds the interrupted turn's session write at park time. The previous approach reconstructed that batch on resume by reconciling the checkpoint against the Session's history, which broke context wrapper propagation, legacy sessions, and detached reconnects, and could not prove what the park had withheld. The park now registers the withheld batch on the existing RunState._pending_session_write slot with a held marker. Registering is not writing: the Session is only touched at a gate-legal exit of a later resume, where the batch lands ahead of the resolved turn's items in one ordered append and inherits the digest-based crash recovery. A run-again checkpoint settles at entry, a detached exit folds the resolved items into the standing batch, an emptied resolved turn discards it, and the blocked-output redaction never sees it raw. The history-reconciliation machinery is deleted. --- src/agents/result.py | 9 +- src/agents/run.py | 162 ++++++----- .../run_internal/agent_runner_helpers.py | 31 ++- src/agents/run_internal/run_loop.py | 164 ++++++++---- .../run_internal/session_persistence.py | 253 ++++++++++++------ src/agents/run_state.py | 20 +- ...test_deferred_interrupted_session_write.py | 99 ------- 7 files changed, 418 insertions(+), 320 deletions(-) diff --git a/src/agents/result.py b/src/agents/result.py index 70d48fe3ef..1499a1edd5 100644 --- a/src/agents/result.py +++ b/src/agents/result.py @@ -46,7 +46,7 @@ ProcessedResponse, QueueCompleteSentinel, ) -from .run_state import RunState +from .run_state import RunState, _PendingSessionWrite from .stream_events import StreamEvent from .tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult from .tracing import Trace @@ -156,6 +156,9 @@ def _populate_state_from_result( else: state._generated_prompt_cache_key = getattr(result, "_generated_prompt_cache_key", None) state._pending_input = copy.deepcopy(getattr(result, "_pending_input_for_state", [])) + state._pending_session_write = copy.deepcopy( + getattr(result, "_pending_session_write", None) + ) state._current_step = getattr(result, "_current_step_for_state", None) state._reasoning_item_id_policy = getattr(result, "_reasoning_item_id_policy", None) @@ -367,6 +370,10 @@ class RunResultBase(abc.ABC): default_factory=list, init=False, repr=False ) """Pending input preserved when a non-streaming result is converted back to RunState.""" + _pending_session_write: _PendingSessionWrite | None = field( + default=None, init=False, repr=False + ) + """Held pending Session write preserved when a non-streaming result becomes a RunState.""" _current_step_for_state: Any = field(default=None, init=False, repr=False) """Current step preserved when a non-streaming result is converted back to RunState.""" diff --git a/src/agents/run.py b/src/agents/run.py index a34e6e27fb..a9cf9a052b 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -140,7 +140,8 @@ _session_get_items, admit_pending_input, commit_server_pending_input, - deferred_interrupted_session_prefix, + defer_interrupted_session_write, + extend_held_session_write, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -149,6 +150,7 @@ save_result_to_session, save_resumed_turn_items, session_items_for_turn, + take_held_session_write, update_run_state_after_resume, ) from .run_internal.tool_use_tracker import ( @@ -156,7 +158,7 @@ hydrate_tool_use_tracker, serialize_tool_use_tracker, ) -from .run_state import RunState +from .run_state import RunState, _PendingSessionWrite from .sandbox.memory.rollouts import terminal_metadata_for_exception from .sandbox.runtime import SandboxRuntime from .tool import dispose_resolved_computers @@ -976,10 +978,6 @@ def _mark_response_hooks_started() -> None: current_task_span.finish(reset_current=True) raise - # The deferred prefix belongs to the resumed turn only: it is filled when that turn - # locates it and emptied once written, so later turns of the same run never - # re-send it. - deferred_session_prefix: list[RunItem] = [] try: while True: validate_output_guardrails_with_server_managed_conversation( @@ -1133,29 +1131,7 @@ def _mark_response_hooks_started() -> None: input_before_turn_rewrite = original_input original_input = turn_result.original_input - # Captured before ``update_run_state_after_resume`` replaces - # ``_session_items``: the park-time list still holds the - # current response's deferred items. - base_session_items = ( - list(run_state._session_items) if run_state is not None else [] - ) generated_items, turn_session_items = resumed_turn_items(turn_result) - deferred_session_prefix = await deferred_interrupted_session_prefix( - session, - base_session_items=base_session_items, - persisted_count=( - run_state._current_turn_persisted_item_count - if run_state is not None - else 0 - ), - session_start=resumed_response_boundary.session_start, - reasoning_item_id_policy=( - run_state._reasoning_item_id_policy - if run_state is not None - else None - ), - wrapper=context_wrapper, - ) session_items.extend(turn_session_items) if run_state is not None: if turn_result.nested_history_owned_items is not None: @@ -1190,37 +1166,62 @@ def _mark_response_hooks_started() -> None: ] if ( - session_persistence_enabled - and turn_session_items - and run_state is not None + run_state is not None # A final output is persisted by the final-turn sweep - # below, which receives the prefix through - # ``final_turn_deferred_prefix``. + # below, which claims the held batch itself. and not isinstance(turn_result.next_step, NextStepFinalOutput) - and not ( - isinstance(turn_result.next_step, NextStepInterruption) - and _should_defer_interrupted_session_items( - current_agent, - run_config, - ) - ) ): - run_state._current_turn_persisted_item_count = ( - await save_resumed_turn_items( - run_state=run_state, - session=session, - items=deferred_session_prefix + turn_session_items, - persisted_count=( - run_state._current_turn_persisted_item_count + if not session_persistence_enabled: + # A detached resume's save is a no-op, so the resolved + # items fold into the standing held batch and settle + # together at the reattach. + extend_held_session_write( + run_state, + run_items=turn_session_items, + reasoning_item_id_policy=( + run_state._reasoning_item_id_policy ), - response_id=turn_result.model_response.response_id, + ) + elif isinstance( + turn_result.next_step, NextStepInterruption + ) and _should_defer_interrupted_session_items( + current_agent, + run_config, + ): + # The re-park keeps deferring: the resolved items join + # the held batch instead of reaching the Session. + defer_interrupted_session_write( + run_state, + session, + run_items=turn_session_items, reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), - store=store_setting, - wrapper=context_wrapper, ) - ) + elif turn_session_items: + run_state._current_turn_persisted_item_count = ( + await save_resumed_turn_items( + run_state=run_state, + session=session, + items=turn_session_items, + held_input=take_held_session_write(run_state), + persisted_count=( + run_state._current_turn_persisted_item_count + ), + response_id=turn_result.model_response.response_id, + reasoning_item_id_policy=( + run_state._reasoning_item_id_policy + ), + store=store_setting, + wrapper=context_wrapper, + ) + ) + else: + # An emptied resolved turn (a handoff input_filter can + # drop every item) discards the held batch: a call + # written without its output poisons the Session + # exactly as the orphaned output does. + take_held_session_write(run_state) # After the resumed turn, treat subsequent turns as fresh so # counters and input saving behave normally. @@ -1274,9 +1275,6 @@ def _mark_response_hooks_started() -> None: return _finalize_result(result) if isinstance(turn_result.next_step, NextStepRunAgain): - # Written above with the resolved turn's items; later turns - # of this run must not re-send it. - deferred_session_prefix = [] continue append_model_response_if_new( @@ -1356,6 +1354,12 @@ def _mark_response_hooks_started() -> None: blocked_message=blocked_message, ) list.extend(session_items, retained_items) + # The redaction derives the sanitized response from the + # run-state boundary, so the raw held batch must not be + # fed into this save: it could resurrect preambles the + # redaction dropped. The declaration is discarded once + # the blocked outcome is decided. + take_held_session_write(run_state) try: await save_final_turn_items_after_guardrails( session=session, @@ -1399,6 +1403,7 @@ def _mark_response_hooks_started() -> None: _attempt_input_guardrail_results() ), items=final_turn_items, + held_input=take_held_session_write(run_state), response_id=turn_result.model_response.response_id, store=store_setting, wrapper=context_wrapper, @@ -1406,12 +1411,7 @@ def _mark_response_hooks_started() -> None: raise final_turn_items = _final_turn_items_for_persistence( - # Same reason as the streamed path: with output - # guardrails this rebuilds the whole current response - # and would recover the deferred prefix, but WITHOUT - # them it returns these items verbatim — and a resume - # may run without the guardrails the park had. - deferred_session_prefix + list(turn_session_items), + list(turn_session_items), current_processed_response, run_state, current_agent, @@ -1428,6 +1428,10 @@ def _mark_response_hooks_started() -> None: session_persistence_enabled=session_persistence_enabled, input_guardrail_results=_attempt_input_guardrail_results(), items=final_turn_items, + # Safe even when the guardrail rebuild above already + # recovered the parked response: the save deduplicates + # the combined batch. + held_input=take_held_session_write(run_state), response_id=turn_result.model_response.response_id, store=store_setting, wrapper=context_wrapper, @@ -2110,21 +2114,36 @@ async def _save_max_turns_handler_output( run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): - if session_persistence_enabled and not ( - _should_defer_interrupted_session_items( + held_record: _PendingSessionWrite | None = None + if session_persistence_enabled and not input_guardrails_triggered( + _attempt_input_guardrail_results() + ): + # Persist session items but skip approval placeholders. + input_items_for_save_interruption: list[TResponseInputItem] = ( + session_input_items_for_persistence + if session_input_items_for_persistence is not None + else [] + ) + if _should_defer_interrupted_session_items( current_agent, run_config, - ) - ): - if not input_guardrails_triggered( - _attempt_input_guardrail_results() ): - # Persist session items but skip approval placeholders. - input_items_for_save_interruption: list[TResponseInputItem] = ( - session_input_items_for_persistence - if session_input_items_for_persistence is not None - else [] + # The gate withholds this write until the output + # guardrails decide; declaring the batch on the + # checkpoint lets a resume settle it at a gate-legal + # exit instead of losing it. + held_record = defer_interrupted_session_write( + run_state, + session, + input_items=input_items_for_save_interruption, + run_items=session_items_for_turn(turn_result), + reasoning_item_id_policy=( + run_state._reasoning_item_id_policy + if run_state is not None + else None + ), ) + else: await save_result_to_session( session, input_items_for_save_interruption, @@ -2157,6 +2176,7 @@ async def _save_max_turns_handler_output( ) result = build_interruption_result( result_input=interruption_result_input2, + held_session_write=held_record, session_items=session_items, model_responses=model_responses, current_agent=current_agent, diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 6662f71e26..21e7a4874b 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Mapping +import copy +from collections.abc import Mapping, Sequence from typing import Any, cast from openai.types.responses.response_usage import OutputTokensDetails @@ -18,7 +19,7 @@ from ..result import RunResult from ..run_config import ReasoningItemIdPolicy, RunConfig from ..run_context import RunContextWrapper, TContext -from ..run_state import RunState +from ..run_state import RunState, _PendingSessionWrite from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult from ..tracing import Span from ..tracing.config import TracingConfig @@ -456,8 +457,13 @@ def build_interruption_result( generated_items: list[RunItem], run_state: RunState | None, original_input: str | list[TResponseInputItem], + held_session_write: _PendingSessionWrite | None = None, ) -> RunResult: - """Create a RunResult for an interruption path.""" + """Create a RunResult for an interruption path. + + ``held_session_write`` carries a held pending write registered by a park with no + live ``RunState``; with one, the record is read from the state itself. + """ identity_root_agent = ( run_state._starting_agent if run_state is not None and run_state._starting_agent is not None @@ -488,6 +494,12 @@ def build_interruption_result( if run_state is not None: result._current_turn_persisted_item_count = run_state._current_turn_persisted_item_count result._trace_state = run_state._trace_state + # The held pending write must survive the result checkpoint: a non-streamed + # caller serializes ``result.to_state()``, which has no live ``RunState`` to + # read the declaration from. + result._pending_session_write = copy.deepcopy(run_state._pending_session_write) + elif held_session_write is not None: + result._pending_session_write = copy.deepcopy(held_session_write) result._original_input = copy_input_items(original_input) return result @@ -582,9 +594,15 @@ async def save_final_turn_items_after_guardrails( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, + held_input: Sequence[TResponseInputItem] | None = None, ) -> int: - """Persist deferred final-turn items without skipping a partially persisted resumed turn.""" - if not session_persistence_enabled or not items: + """Persist deferred final-turn items without skipping a partially persisted resumed turn. + + ``held_input`` is a claimed held batch that must land ahead of the final items in + the same append. It is safe to pass even when the rebuilt final items already + contain the parked response: the save deduplicates the combined batch. + """ + if not session_persistence_enabled or (not items and not held_input): return 0 if input_guardrails_triggered(input_guardrail_results): return 0 @@ -597,11 +615,12 @@ async def save_final_turn_items_after_guardrails( reasoning_item_id_policy=run_state._reasoning_item_id_policy, store=store, wrapper=wrapper, + held_input=held_input, ) return run_state._current_turn_persisted_item_count return await save_result_to_session( session, - [], + list(held_input) if held_input else [], list(items), run_state, response_id=response_id, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 1fd5b75553..62a4de420f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -175,7 +175,8 @@ _session_get_items, admit_pending_input, commit_server_pending_input, - deferred_interrupted_session_prefix, + defer_interrupted_session_write, + extend_held_session_write, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, @@ -185,6 +186,7 @@ save_result_to_session, save_resumed_turn_items, session_items_for_turn, + take_held_session_write, update_run_state_after_resume, ) from .streaming import stream_step_items_to_queue, stream_step_result_to_queue @@ -393,11 +395,19 @@ async def _save_resumed_stream_items( server_conversation_tracker=server_conversation_tracker, streamed_result=streamed_result, ): + if session is not None: + # Nothing of this run may persist (an input guardrail tripped), so the + # held batch must not outlive the run either; a detached run keeps it + # riding for the reattach instead. + take_held_session_write(run_state) return streamed_result._current_turn_persisted_item_count = await save_resumed_turn_items( run_state=run_state, session=session, items=items, + # An exit that saves nothing is not settling; the batch keeps riding (a + # re-park) or is discarded explicitly at the exit that owns that decision. + held_input=take_held_session_write(run_state) if items else None, persisted_count=streamed_result._current_turn_persisted_item_count, response_id=response_id, reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, @@ -560,6 +570,11 @@ async def _finalize_streamed_final_output( owner_starts=owner_starts, blocked_message=blocked_message, ) + # The redaction derives the sanitized response from the run-state boundary, so + # the raw held batch must not be fed into this save: it could resurrect + # preambles the redaction dropped. The declaration is discarded once the + # blocked outcome is decided. + take_held_session_write(streamed_result._state) if retained_items: try: await save_items(retained_items, response_id, store_setting) @@ -1200,10 +1215,6 @@ async def _save_max_turns_items( raise try: - # The deferred prefix belongs to the resumed turn only: it is filled when that turn - # locates it and emptied once written, so later turns of the same run never - # re-send it. - deferred_session_prefix: list[RunItem] = [] while True: validate_output_guardrails_with_server_managed_conversation( current_agent, @@ -1346,13 +1357,6 @@ async def _save_max_turns_items( base_session_items = ( list(run_state._session_items) if run_state is not None else [] ) - deferred_session_prefix = await deferred_interrupted_session_prefix( - session, - base_session_items=base_session_items, - persisted_count=streamed_result._current_turn_persisted_item_count, - session_start=resumed_response_boundary.session_start, - reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, - ) streamed_result._model_input_items = generated_items streamed_result.new_items = base_session_items + list(turn_session_items) if turn_result.nested_history_owned_items is not None: @@ -1408,23 +1412,44 @@ async def _save_max_turns_items( *accepted_tool_output_guardrail_results, *turn_result.tool_output_guardrail_results, ] + # A resume can interrupt again (a partial approval of a + # multi-approval response). If the gate still defers, the + # resolved items join the held batch; a detached re-park folds + # them the same way. An emptied resolved turn discards the + # batch instead: a call written without its output poisons the + # Session exactly as the orphaned output does. Mirrors the + # non-streaming path. + if session is None: + extend_held_session_write( + run_state, + run_items=turn_session_items, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + ) + reinterruption_items: list[RunItem] = [] + elif _should_defer_interrupted_session_items( + current_agent, + run_config, + ): + defer_interrupted_session_write( + run_state, + session, + run_items=turn_session_items, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + ) + reinterruption_items = [] + elif turn_session_items: + reinterruption_items = list(turn_session_items) + else: + take_held_session_write(run_state) + reinterruption_items = [] await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_resumed_items, - # A resume can interrupt again (a partial approval of a - # multi-approval response). If the gate still defers, keep - # deferring; otherwise this write is the deferred prefix's - # last chance — it bumps the persisted count, so leaving the - # prefix out here would orphan the parked calls for every - # later resume. Mirrors the non-streaming path's guard. - items=( - [] - if _should_defer_interrupted_session_items( - current_agent, - run_config, - ) - else deferred_session_prefix + list(turn_session_items) - ), + items=reinterruption_items, response_id=turn_result.model_response.response_id, store_setting=store_setting, interruptions=approvals_from_step(turn_result.next_step), @@ -1446,14 +1471,23 @@ async def _save_max_turns_items( if run_state is not None: run_state._current_agent = current_agent _publish_streamed_result_agent(streamed_result, current_agent) - # An empty resolved turn (a handoff input_filter can drop - # every item) must not leave the prefix stranded on its own: - # a call written without its output poisons the Session just - # as the orphaned output does. Keep deferring instead. + # A detached exit folds the resolved items into the held batch; + # an emptied resolved turn (a handoff input_filter can drop + # every item) discards the batch instead: a call written + # without its output poisons the Session just as the orphaned + # output does. + if session is None: + extend_held_session_write( + run_state, + run_items=turn_session_items, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + ) + elif not turn_session_items: + take_held_session_write(run_state) await _save_resumed_items( - (deferred_session_prefix + list(turn_session_items)) - if turn_session_items - else [], + list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, store_setting, ) @@ -1471,6 +1505,11 @@ async def _save_max_turns_items( continue if isinstance(turn_result.next_step, NextStepFinalOutput): + if session is None: + # A detached final output has no Session to settle against + # and the run ends here, so the batch is discarded rather + # than left to invalidate the completed run's checkpoint. + take_held_session_write(run_state) await _finalize_streamed_final_output( streamed_result=streamed_result, agent=current_agent, @@ -1478,12 +1517,7 @@ async def _save_max_turns_items( output=turn_result.next_step.output, context_wrapper=context_wrapper, save_items=_save_resumed_items, - # The deferred prefix rides here too: with output guardrails - # ``_final_turn_items_for_persistence`` rebuilds the whole - # current response and would recover it, but WITHOUT them it - # returns these items verbatim — and a resume may legitimately - # run without the guardrails the park had. - items=deferred_session_prefix + list(turn_session_items), + items=list(turn_session_items), model_response=turn_result.model_response, processed_response=( turn_result.processed_response @@ -1500,14 +1534,23 @@ async def _save_max_turns_items( break if isinstance(turn_result.next_step, NextStepRunAgain): - # An empty resolved turn (a handoff input_filter can drop - # every item) must not leave the prefix stranded on its own: - # a call written without its output poisons the Session just - # as the orphaned output does. Keep deferring instead. + # A detached exit folds the resolved items into the held batch; + # an emptied resolved turn (a handoff input_filter can drop + # every item) discards the batch instead: a call written + # without its output poisons the Session just as the orphaned + # output does. + if session is None: + extend_held_session_write( + run_state, + run_items=turn_session_items, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + ) + elif not turn_session_items: + take_held_session_write(run_state) await _save_resumed_items( - (deferred_session_prefix + list(turn_session_items)) - if turn_session_items - else [], + list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, store_setting, ) @@ -1979,17 +2022,30 @@ def _record_max_turns_handler_output( run_state._current_turn_persisted_item_count = ( streamed_result._current_turn_persisted_item_count ) + parked_items_deferred = _should_defer_interrupted_session_items( + current_agent, + run_config, + ) + if parked_items_deferred and await _should_persist_stream_items( + session=session, + server_conversation_tracker=server_conversation_tracker, + streamed_result=streamed_result, + ): + # The gate withholds this write until the output guardrails + # decide; declaring the batch on the checkpoint lets a resume + # settle it at a gate-legal exit instead of losing it. + defer_interrupted_session_write( + run_state, + session, + run_items=turn_session_items, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + ) await _finalize_streamed_interruption( streamed_result=streamed_result, save_items=_save_stream_items_with_count, - items=( - [] - if _should_defer_interrupted_session_items( - current_agent, - run_config, - ) - else turn_session_items - ), + items=([] if parked_items_deferred else turn_session_items), response_id=turn_result.model_response.response_id, store_setting=store_setting, interruptions=approvals_from_step(turn_result.next_step), diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index ba2ab4d802..60c8148d6b 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -15,7 +15,6 @@ from typing import Any, cast from .. import _debug -from .._tool_identity import get_hosted_mcp_approval_request_identity from ..exceptions import UserError from ..items import ( HandoffOutputItem, @@ -42,7 +41,7 @@ from ..memory.session import _call_session_method, _get_session_wrapper from ..models.fake_id import FAKE_RESPONSES_ID from ..run_context import RunContextWrapper -from ..run_state import RunState +from ..run_state import RunState, _PendingSessionWrite from .items import ( NestedHistoryOwnedItem, NestedHistoryOwnedItemRef, @@ -70,10 +69,6 @@ SingleStepResult, ) -# How far past the prefix's own length to look for it: enough to clear the outputs a -# previous write of the same response would have appended after it. -_PREFIX_MATCH_LOOKBACK = 8 - __all__ = [ "admit_pending_input", "commit_server_pending_input", @@ -85,7 +80,9 @@ "resumed_turn_items", "save_result_to_session", "save_resumed_turn_items", - "deferred_interrupted_session_prefix", + "defer_interrupted_session_write", + "extend_held_session_write", + "take_held_session_write", "resume_pending_session_write", "update_run_state_after_resume", "rewind_session_items", @@ -200,6 +197,29 @@ def retain_accepted_admissions(items: list[RunItem]) -> None: return True +def _session_method_accepts_limit(method: Any) -> bool: + """Return whether a ``get_items`` implementation can be passed ``limit``. + + A structural ``Session`` written against a pre-``limit`` release may declare + ``get_items(self)`` alone; passing ``limit`` to it raises ``TypeError`` and turns + every internal tail read into a hard failure. When the signature cannot be + inspected, assume the released shape. + """ + try: + parameters = inspect.signature(method).parameters.values() + except Exception: + return True + return any( + ( + parameter.name == "limit" + and parameter.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + ) + or parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in parameters + ) + + async def _session_get_items( session: Session, limit: int | None | object = _SESSION_LIMIT_UNSET, @@ -213,6 +233,12 @@ async def _session_get_items( async def read_items() -> list[TResponseInputItem]: if limit is _SESSION_LIMIT_UNSET: result = await _call_session_method(session.get_items, wrapper=session_wrapper) + elif not _session_method_accepts_limit(session.get_items): + # Fall back to a full read and apply the released ``limit`` semantics + # locally: the latest ``limit`` items in chronological order. + result = await _call_session_method(session.get_items, wrapper=session_wrapper) + if isinstance(limit, int): + result = list(result)[-limit:] if limit > 0 else [] else: result = await _call_session_method( session.get_items, limit=limit, wrapper=session_wrapper @@ -777,13 +803,22 @@ async def save_resumed_turn_items( store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, run_state: RunState | None = None, + held_input: Sequence[TResponseInputItem] | None = None, ) -> int: - """Persist resumed turn items and return the updated persisted count.""" - if session is None or not items: + """Persist resumed turn items and return the updated persisted count. + + ``held_input`` carries a claimed held batch (see ``take_held_session_write``) into + the same append as the resolved turn's items, ahead of them. One ordered write + keeps the interrupted ``function_call`` before its output and lets the whole batch + register as the one pending append with digest-based crash recovery; settling the + batch separately would either trip the single-slot rule or advance the persisted + count and slice the resolved items out of their own save. + """ + if session is None or (not items and not held_input): return persisted_count saved_count = await save_result_to_session( session, - [], + list(held_input) if held_input else [], list(items), None, response_id=response_id, @@ -800,89 +835,124 @@ async def save_resumed_turn_items( return persisted_count + saved_count -async def deferred_interrupted_session_prefix( +def defer_interrupted_session_write( + run_state: RunState | None, session: Session | None, *, - base_session_items: Sequence[RunItem], - persisted_count: int, - session_start: int | None, + input_items: Sequence[TResponseInputItem] | None = None, + run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, - wrapper: RunContextWrapper[Any] | None = None, -) -> list[RunItem]: - """Return the interrupted response's session items that are still missing from the Session. - - When ``_should_defer_interrupted_session_items`` gated the interruption-time write, - nothing of the current response reached the Session, and a resume that continues the - run must persist that prefix ahead of the resolved turn's items — otherwise a tool - output lands without its ``function_call`` and the provider rejects every later run - over the Session. - - The park-time decision is NOT re-evaluated against the live configuration: the caller - may resume with a different ``tool_use_behavior`` or guardrail set. It is read from - checkpoint state (``persisted_count``) and then CONFIRMED against the Session itself, - because that counter can legitimately lie: the resumed-safety validator resets it to - zero for a detached resume, and a later resume that reconnects the original Session - would otherwise rewrite items it already holds. The confirmation matches the WHOLE - converted prefix as a contiguous run inside the Session's tail, all or nothing: an - assistant preamble repeats verbatim across turns, so filtering item by item against an - unordered set would delete a legitimate occurrence from history while still appending - the calls around it. Either this exact response is already there (write nothing) or - none of it is (write all of it), which makes the write idempotent by construction. +) -> _PendingSessionWrite | None: + """Register the interruption's withheld batch as a held pending Session write. + + Registering is not writing: this touches only the checkpoint, never the Session, + so the output-guardrail persistence gate stays intact. The batch settles, extends + or is discarded only at a gate-legal point of a later resume. A standing held + record is replaced by the superset of both batches, so a repeated park (a partial + approval interrupting again) keeps one canonical batch. A standing record that is + not held means a resumed append is mid-flight, which the existing single-slot rule + treats as a caller bug. + + Items are converted and deduplicated with the same helpers the real save uses, and + the count is taken over the converted items: approval placeholders drop out in + conversion, so counting the raw run items would corrupt the persisted count. + Returns the record so a caller without a live ``RunState`` (a fresh non-streamed + park) can attach it to its result checkpoint. """ - if persisted_count != 0 or session_start is None: - return [] - prefix = list(base_session_items[session_start:]) - if not prefix or session is None: - return prefix - # Suppress only what the Session provably already holds, and only by an identity that - # cannot collide: a tool call and a tool output are keyed by ``(type, call_id)``, which - # is unique per turn. Everything else — an assistant preamble, a reasoning item — is - # kept unconditionally, because those repeat verbatim across turns and a coincidental - # match would delete a legitimate occurrence from history. So a partially written - # response (its calls persisted by an earlier attempt, its output not yet) contributes - # exactly the missing half instead of duplicating or losing anything. - paired = [(item, run_item_to_input_item(item, reasoning_item_id_policy)) for item in prefix] - keyed = [key for _, written in paired if (key := _identity_key(written)) is not None] - if not keyed: - return prefix - tail = await _session_get_items( - session, limit=len(keyed) * 2 + _PREFIX_MATCH_LOOKBACK, wrapper=wrapper + pending = run_state._pending_session_write if run_state is not None else None + if pending is not None and not pending.get("held"): + raise UserError("Resolve the pending Session write before saving another batch") + + converted_input: list[TResponseInputItem] = [] + if input_items: + converted_input = normalize_input_items_for_api( + [ + ensure_input_item_format(item) + for item in ItemHelpers.input_to_new_input_list(list(input_items)) + ] + ) + converted_run_items: list[TResponseInputItem] = [] + for run_item in run_items: + as_input = run_item_to_input_item(run_item, reasoning_item_id_policy) + if as_input is None: + continue + converted_run_items.append(ensure_input_item_format(as_input)) + + base_items = list(pending["items"]) if pending is not None else [] + items = deduplicate_input_items_preferring_latest( + base_items + converted_input + converted_run_items ) - present = {key for item in tail if (key := _identity_key(item)) is not None} - if not present: - return prefix - return [item for item, written in paired if _identity_key(written) not in present] - - -def _identity_key(item: TResponseInputItem | None) -> tuple[str, str] | None: - """Return a collision-free identity for an item, or ``None`` when it has none. - - Only items carrying a unique per-turn id have one, and each family names it - differently: function calls and their outputs use ``call_id``, a hosted MCP approval - request uses ``id`` (read through the canonical - ``get_hosted_mcp_approval_request_identity`` rather than a local rule), and its - response points back with ``approval_request_id``. Everything else — an assistant - message, a reasoning item — returns ``None`` and is therefore never suppressed: two - with the same content are indistinguishable and must not be treated as the same row. - """ - if not isinstance(item, dict): + if isinstance(session, OpenAIConversationsSession): + items = [_sanitize_openai_conversation_item(item) for item in items] + items = [ + item for item in items if not _is_unpersistable_for_openai_conversation(item) + ] + if not items: return None - item_type = item.get("type") - if not isinstance(item_type, str): + + session_id = ( + session.session_id + if session is not None + else (pending["session_id"] if pending is not None else None) + ) + if session_id is None: return None - if item_type == "mcp_approval_request": - identity = get_hosted_mcp_approval_request_identity(item) - request_id = identity.request_id if identity is not None else None - return (item_type, request_id) if request_id else None - if item_type == "mcp_approval_response": - approval_request_id = item.get("approval_request_id") - return ( - (item_type, approval_request_id) - if isinstance(approval_request_id, str) and approval_request_id - else None + record: _PendingSessionWrite = { + "session_id": session_id, + "items": copy.deepcopy(items), + "before": None, + "persisted_count": ( + run_state._current_turn_persisted_item_count if run_state is not None else 0 ) - call_id = item.get("call_id") - return (item_type, call_id) if isinstance(call_id, str) and call_id else None + + len(converted_run_items), + "held": True, + } + if run_state is not None: + run_state._pending_session_write = record + return record + + +def extend_held_session_write( + run_state: RunState | None, + *, + run_items: Sequence[RunItem], + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, +) -> None: + """Fold a detached exit's resolved items into the standing held batch. + + With no Session attached the resolved turn's save is a no-op, so the executed + tool output exists only in this process; folding it into the held batch lets the + reattaching resume settle call and output together. Does nothing when no held + batch stands. + """ + if run_state is None or run_state._pending_session_write is None: + return + if not run_state._pending_session_write.get("held"): + return + defer_interrupted_session_write( + run_state, + None, + run_items=run_items, + reasoning_item_id_policy=reasoning_item_id_policy, + ) + + +def take_held_session_write(run_state: RunState | None) -> list[TResponseInputItem]: + """Claim the standing held batch for a settling write and free the slot. + + The caller must hand the returned items to a Session save in the same exit + (``held_input`` on ``save_resumed_turn_items``, or the input positional of + ``save_result_to_session``), or drop them deliberately when the exit's contract is + to discard the batch. The slot is freed first so the settling write can register + itself as the one pending append and inherit the digest-based crash recovery. + """ + if run_state is None: + return [] + pending = run_state._pending_session_write + if pending is None or not pending.get("held"): + return [] + run_state._pending_session_write = None + return list(pending["items"]) async def resume_pending_session_write( @@ -900,6 +970,19 @@ async def resume_pending_session_write( pending = run_state._pending_session_write if pending is None: return + if pending.get("held"): + # A held batch is the write the interruption park withheld under the + # output-guardrail gate, and resume entry is not a gate-legal settle point, so + # the declaration rides the checkpoint untouched; in particular a detached + # resume must not fail the boot over a batch it cannot settle. The exception + # is a run-again checkpoint: the parked response's outputs already went back + # to the model, which only happens after the gate stopped applying to that + # response, so the batch settles here, before the next model call. This is + # also the only settle point such a checkpoint will ever reach, because the + # run-again turn's saves never arm ``resumed_write_state``. + if session is None or not isinstance(run_state._current_step, NextStepRunAgain): + return + pending.pop("held", None) if run_state._session_write_in_progress: raise UserError("The pending Session write is already in progress for this RunState") if session is None or session.session_id != pending["session_id"]: diff --git a/src/agents/run_state.py b/src/agents/run_state.py index d79e0781a4..d07b6c587a 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -36,7 +36,7 @@ ProgramOutput, ) from pydantic import BaseModel, StringConstraints, TypeAdapter, ValidationError -from typing_extensions import TypedDict, TypeVar +from typing_extensions import NotRequired, TypedDict, TypeVar from ._run_state_agent_identity import ( _build_agent_identity_keys_by_id, @@ -168,12 +168,21 @@ class _PendingSessionWrite(TypedDict): - """One canonical resumed-output append awaiting acknowledgement.""" + """One canonical resumed-output append awaiting acknowledgement. + + ``held`` marks a batch the interruption park withheld because the agent's output + guardrails had not approved the turn yet. A held batch was never offered to the + Session, so ``before`` stays ``None`` until a gate-legal exit starts settling it; + from that point it is an ordinary pending write and the digest reconciliation + recovers a half-acknowledged append. Absent or ``False`` keeps the released + meaning: an append already approved for eager settlement on resume entry. + """ session_id: str items: list[TResponseInputItem] before: list[str] | None persisted_count: int + held: NotRequired[bool] def _default_run_state_validation_error( @@ -227,7 +236,8 @@ def _default_run_state_validation_error( ), "1.17": ( "Persists Docker container labels and current-response generated-item ownership across " - "resume flows, including pending resumed Session writes and terminal-unrecoverable runs." + "resume flows, including pending resumed Session writes, their held-at-interruption " + "variant, and terminal-unrecoverable runs." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -4376,7 +4386,9 @@ async def _build_run_state_from_json( (schema_major, schema_minor) < (1, 17) or not isinstance(state._current_step, NextStepRunAgain | NextStepInterruption) or not isinstance(pending_write, dict) - or set(pending_write) != {"session_id", "items", "before", "persisted_count"} + or set(pending_write) - {"held"} != {"session_id", "items", "before", "persisted_count"} + or ("held" in pending_write and type(pending_write["held"]) is not bool) + or (pending_write.get("held") is True and pending_write.get("before") is not None) or not isinstance(pending_write.get("session_id"), str) or not isinstance(pending_write.get("items"), list) or not pending_write["items"] diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index cc204c8cbf..366a625c3e 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -4,11 +4,6 @@ from typing import Literal import pytest -from openai.types.responses import ( - ResponseFunctionToolCall, - ResponseOutputMessage, - ResponseOutputText, -) from agents import ( Agent, @@ -395,100 +390,6 @@ async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session( assert call_ids.count("call_PARKED_2") == 1 -@pytest.mark.asyncio -async def test_an_item_that_merely_looks_familiar_is_never_dropped() -> None: - """Only ``call_id``-keyed items can be recognized as already written. - - An assistant preamble repeats verbatim across turns, so a Session tail can hold an - identical one from an EARLIER turn while none of the current response is saved. - Suppressing by content would delete a legitimate occurrence from history while still - appending the calls around it, so items without a collision-free identity are always - kept. - """ - from agents.items import MessageOutputItem, ToolCallItem - from agents.run_internal.session_persistence import deferred_interrupted_session_prefix - - agent = Agent(name="preamble") - text = "Let me check that." - preamble = MessageOutputItem( - agent=agent, - raw_item=ResponseOutputMessage( - id="__fake_id__", - content=[ResponseOutputText(text=text, annotations=[], type="output_text")], - role="assistant", - status="completed", - type="message", - ), - ) - call = ToolCallItem( - agent=agent, - raw_item=ResponseFunctionToolCall( - id="__fake_id__", - call_id="call_NEW", - name="write_thing", - arguments="{}", - type="function_call", - ), - ) - # The Session already holds an identical preamble from a previous turn, and nothing - # of the current response. - session = SimpleListSession( - history=[ - {"role": "user", "content": "hi"}, - { - "id": "__fake_id__", - "content": [{"annotations": [], "text": text, "type": "output_text"}], - "role": "assistant", - "status": "completed", - "type": "message", - }, - ] - ) - - kept = await deferred_interrupted_session_prefix( - session, - base_session_items=[preamble, call], - persisted_count=0, - session_start=0, - ) - - assert kept == [preamble, call] - - -@pytest.mark.asyncio -async def test_hosted_mcp_approval_identities_are_recognized() -> None: - """Not every family names its id ``call_id``. - - A hosted MCP approval request identifies itself with ``id`` and its response points - back with ``approval_request_id``. Unrecognized, a partially written response would - append requests the Session already holds, and duplicate request ids corrupt the - history the next model call reads. - """ - from agents.run_internal.session_persistence import _identity_key - - request: TResponseInputItem = { - "type": "mcp_approval_request", - "id": "mcpr_123", - "name": "do_it", - "server_label": "srv", - "arguments": "{}", - } - response: TResponseInputItem = { - "type": "mcp_approval_response", - "approval_request_id": "mcpr_123", - "approve": True, - } - - assert _identity_key(request) == ("mcp_approval_request", "mcpr_123") - # The response is a DIFFERENT row than the request it answers: same id, distinct - # identity, so persisting the response never suppresses the request or vice versa. - assert _identity_key(response) == ("mcp_approval_response", "mcpr_123") - assert _identity_key(request) != _identity_key(response) - # Still nothing for a content-only item. - plain: TResponseInputItem = {"role": "assistant", "content": "hi", "type": "message"} - assert _identity_key(plain) is None - - def make_emptying_handoff_agent() -> Agent: """One response carrying the gated call AND a handoff whose filter empties the turn. From e7df96d6da8f7735fc4bd8ca70f0b07490de048b Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:33:00 +0200 Subject: [PATCH 10/30] test: cover the held pending write across both runners and its serialized contract The acceptance battery drives park, approve, reject, re-park, detached carry, tripwire, guardrail crash, emptied turn, legacy and context-aware sessions, and a failed settle recovered on the next resume, each through both runners and a serialized checkpoint. The resume-path suite pins the held marker's validation and that a checkpoint without the marker keeps its released eager-settle meaning. --- ...test_deferred_interrupted_session_write.py | 845 +++++++++++------- tests/test_run_impl_resume_paths.py | 30 +- 2 files changed, 544 insertions(+), 331 deletions(-) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 366a625c3e..fbbde0a1fd 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Literal +from typing import Any, Literal import pytest @@ -18,6 +18,7 @@ output_guardrail, ) from agents.agent import Agent as AgentType +from agents.exceptions import OutputGuardrailTripwireTriggered from agents.items import TResponseInputItem from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call from tests.utils.simple_session import SimpleListSession @@ -28,6 +29,11 @@ def write_thing(query: str) -> str: return f"wrote:{query}" +@function_tool(name_override="write_other", needs_approval=True) +def write_other(query: str) -> str: + return f"other:{query}" + + @function_tool(name_override="look_up", needs_approval=False) def look_up(query: str) -> str: return f"schema for {query}" @@ -40,20 +46,36 @@ async def always_fine( return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) -DEFERRING_BEHAVIOR = StopAtTools(stop_at_tool_names=["finish"]) +@output_guardrail +async def always_trips( + ctx: RunContextWrapper[object], agent: AgentType[object], output: object +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True) + + +@output_guardrail +async def always_crashes( + ctx: RunContextWrapper[object], agent: AgentType[object], output: object +) -> GuardrailFunctionOutput: + raise RuntimeError("guardrail crashed") + + +# The two conditions that open ``_should_defer_interrupted_session_items``: output +# guardrails and a non-default ``tool_use_behavior``. The approved tool is not in the +# stop list, so the resume resolves into a run-again step rather than a terminal tool +# output. +_DEFERRING_BEHAVIOR = StopAtTools(stop_at_tool_names=["finish"]) -def make_agent( - tool_use_behavior: StopAtTools | Literal["run_llm_again"] = DEFERRING_BEHAVIOR, +def _make_deferring_agent( + tool_use_behavior: StopAtTools | Literal["run_llm_again"] = _DEFERRING_BEHAVIOR, ) -> Agent: + """A gated write on the second model turn, so the resumed boundary has a prefix.""" return Agent( name="deferred repro", instructions="Always call write_thing.", model=ScriptedModel( [ - # Two model turns before the interruption, like a real agent: an - # ungated lookup first, THEN the gated write. The interruption must - # land on turn > 1 so the resumed boundary has an accepted prefix. ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), ModelStep( output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] @@ -62,305 +84,350 @@ def make_agent( ] ), tools=[look_up, write_thing], - # The two conditions that open ``_should_defer_interrupted_session_items``: - # output guardrails AND ``tool_use_behavior != "run_llm_again"``. The approved - # tool is NOT in the stop list, so the resume resolves into - # ``next_step_run_again`` rather than a terminal tool output. output_guardrails=[always_fine], tool_use_behavior=tool_use_behavior, ) -@pytest.mark.asyncio -async def test_deferred_parked_call_is_persisted_when_the_resume_runs_again() -> None: - """An approved tool's ``function_call`` must reach the Session, not only its output. - - With output guardrails and a non-default ``tool_use_behavior``, the interrupted - turn's session items are deferred at interruption time - (``_should_defer_interrupted_session_items``). When the approval resume resolves - into ``next_step_run_again``, the resume-side write only carries the resolved - turn's new items (the tool output), and no later write recovers the deferred - ``function_call``. The Session ends up with a ``function_call_output`` whose call - was never persisted, and the Responses API rejects every later run over that - Session with "No tool call found for function call output". - """ - session = SimpleListSession() - agent = make_agent() +def _make_multi_approval_agent( + tool_use_behavior: StopAtTools | Literal["run_llm_again"] = _DEFERRING_BEHAVIOR, +) -> Agent: + """One deferred model response carrying two approval-required calls.""" + return Agent( + name="deferred repro (multi)", + instructions="Call both tools.", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("write_other", {"query": "x"}, call_id="call_PARKED_2"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing, write_other], + output_guardrails=[always_fine], + tool_use_behavior=tool_use_behavior, + ) - first = Runner.run_streamed(agent, "do the thing", session=session) - async for _ in first.stream_events(): - pass - assert len(first.interruptions) == 1 - # Park in an external store and resume from it, as a multi-process app must: - # the RunState round-trips through JSON between the two runs. - serialized = json.dumps(first.to_state().to_json()) - state = await RunState.from_json(agent, json.loads(serialized)) - state.approve(state.get_interruptions()[0]) +def _make_terminal_tool_agent( + *, with_guardrails: bool = True, tripping: bool = False, crashing: bool = False +) -> Agent: + """The approved tool is terminal, so the resume ends in a final output.""" + guardrails = [always_fine] + if tripping: + guardrails = [always_trips] + if crashing: + guardrails = [always_crashes] + return Agent( + name="deferred repro (terminal)", + instructions="Always call write_thing.", + model=ScriptedModel( + [ + ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + ), + ] + ), + tools=[look_up, write_thing], + output_guardrails=guardrails if with_guardrails else [], + tool_use_behavior=StopAtTools(stop_at_tool_names=["write_thing"]), + ) - resumed = Runner.run_streamed(agent, state, session=session) - async for _ in resumed.stream_events(): - pass - assert resumed.final_output == "done" - items = await session.get_items() - call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} - orphaned = [ - item - for item in items - if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids - ] - assert orphaned == [], ( - "the approved tool's function_call never reached the Session; " - f"orphaned outputs: {[item.get('call_id') for item in orphaned]}" +def _make_emptying_handoff_agent() -> Agent: + """The gated call rides one response with a handoff whose filter empties the turn.""" + from agents import HandoffInputData, handoff + + def empties(data: HandoffInputData) -> HandoffInputData: + return HandoffInputData(input_history=data.input_history, pre_handoff_items=(), new_items=()) + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel( + [ + ModelStep(output=[assistant_message("done")]), + ModelStep(output=[assistant_message("done")]), + ] + ), + ) + return Agent( + name="deferred repro (emptied turn)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing], + handoffs=[handoff(target, input_filter=empties)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, ) - assert "call_PARKED" in call_ids -@pytest.mark.asyncio -async def test_park_time_deferral_survives_a_tool_use_behavior_change_on_resume() -> None: - """The deferral decision is the checkpoint's, not the resuming configuration's. - - Parking defers the interrupted turn's write (guardrails + non-default - ``tool_use_behavior``); the caller then resumes with ``"run_llm_again"``. Deriving - the decision from today's gate would conclude nothing was deferred and drop the - parked ``function_call`` again — it must come from checkpoint state instead - (``_current_turn_persisted_item_count``). - """ - session = SimpleListSession() +class _ContextRequiringSession(SimpleListSession): + """Track whether internal reads and writes carry the run's context wrapper.""" - first = Runner.run_streamed(make_agent(), "do the thing", session=session) - async for _ in first.stream_events(): - pass - assert len(first.interruptions) == 1 + def __init__(self) -> None: + super().__init__() + self.wrapperless_operations = 0 - resume_agent = make_agent(tool_use_behavior="run_llm_again") - serialized = json.dumps(first.to_state().to_json()) - state = await RunState.from_json(resume_agent, json.loads(serialized)) - state.approve(state.get_interruptions()[0]) + async def get_items( + self, limit: int | None = None, *, wrapper: RunContextWrapper[Any] | None = None + ) -> list[TResponseInputItem]: + if limit is not None and wrapper is None: + self.wrapperless_operations += 1 + return await super().get_items(limit) - resumed = Runner.run_streamed(resume_agent, state, session=session) - async for _ in resumed.stream_events(): - pass + async def add_items( + self, items: list[TResponseInputItem], *, wrapper: RunContextWrapper[Any] | None = None + ) -> None: + if wrapper is None: + self.wrapperless_operations += 1 + await super().add_items(items) - items = await session.get_items() - call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} - orphaned = [ - item - for item in items - if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids - ] - assert orphaned == [] - assert "call_PARKED" in call_ids + async def pop_item( + self, *, wrapper: RunContextWrapper[Any] | None = None + ) -> TResponseInputItem | None: + return await super().pop_item() + async def clear_session(self, *, wrapper: RunContextWrapper[Any] | None = None) -> None: + await super().clear_session() -@pytest.mark.asyncio -async def test_non_deferred_park_is_not_double_written_on_resume() -> None: - """The other direction of deriving from state: with ``"run_llm_again"`` throughout, - the interruption-time write runs (no deferral) and bumps the persisted count, so the - resume must not write the parked ``function_call`` a second time.""" - session = SimpleListSession() - agent = make_agent(tool_use_behavior="run_llm_again") - first = Runner.run_streamed(agent, "do the thing", session=session) - async for _ in first.stream_events(): - pass - assert len(first.interruptions) == 1 +class _LegacyGetItemsSession(SimpleListSession): + """A pre-limit Session whose ``get_items`` takes no arguments at all.""" - serialized = json.dumps(first.to_state().to_json()) - state = await RunState.from_json(agent, json.loads(serialized)) - state.approve(state.get_interruptions()[0]) + async def get_items(self) -> list[TResponseInputItem]: # type: ignore[override] + return await super().get_items() - resumed = Runner.run_streamed(agent, state, session=session) - async for _ in resumed.stream_events(): - pass - items = await session.get_items() - parked_calls = [ - item +class _AppendRecordingSession(SimpleListSession): + """Record each ``add_items`` batch to observe write ordering and granularity.""" + + def __init__(self) -> None: + super().__init__() + self.batches: list[list[TResponseInputItem]] = [] + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.batches.append(list(items)) + await super().add_items(items) + + +class _FailingResumeSession(SimpleListSession): + """Control append acknowledgement at the public Session boundary.""" + + def __init__(self) -> None: + super().__init__() + self.failure: str | None = None + self.error = RuntimeError("session append failed") + + async def add_items(self, items: list[TResponseInputItem]) -> None: + failure, self.failure = self.failure, None + if failure == "before": + raise self.error + await super().add_items(items) + if failure == "after": + raise self.error + + +async def _run( + agent: Agent, run_input: Any, session: Any, *, streamed: bool +) -> RunResult | RunResultStreaming: + if streamed: + result = Runner.run_streamed(agent, run_input, session=session) + async for _ in result.stream_events(): + pass + return result + return await Runner.run(agent, run_input, session=session) + + +async def _serialized_round_trip(result: RunResult | RunResultStreaming, agent: Agent) -> RunState: + return await RunState.from_json(agent, json.loads(json.dumps(result.to_state().to_json()))) + + +def _call_ids(items: list[TResponseInputItem]) -> list[Any]: + return [item.get("call_id") for item in items if item.get("type") == "function_call"] + + +def _orphaned_outputs(items: list[TResponseInputItem]) -> list[Any]: + calls = set(_call_ids(items)) + return [ + item.get("call_id") for item in items - if item.get("type") == "function_call" and item.get("call_id") == "call_PARKED" + if item.get("type") == "function_call_output" and item.get("call_id") not in calls ] - parked_outputs = [ - item + + +def _parked_pair(items: list[TResponseInputItem]) -> list[str]: + return [ + str(item.get("type")) for item in items - if item.get("type") == "function_call_output" and item.get("call_id") == "call_PARKED" + if isinstance(item, dict) and item.get("call_id") == "call_PARKED" ] - assert len(parked_calls) == 1 - assert len(parked_outputs) == 1 -@function_tool(name_override="write_other", needs_approval=True) -def write_other(query: str) -> str: - return f"other:{query}" +async def _parked_and_approved( + agent: Agent, session: Any, *, streamed: bool, resume_agent: Agent | None = None +) -> RunState: + first = await _run(agent, "do the thing", session, streamed=streamed) + assert len(first.interruptions) == 1 + state = await _serialized_round_trip(first, resume_agent or agent) + state.approve(state.get_interruptions()[0]) + return state -def make_multi_approval_agent( - tool_use_behavior: StopAtTools | Literal["run_llm_again"] = DEFERRING_BEHAVIOR, -) -> Agent: - """One deferred model response carrying TWO approval-required calls.""" - return Agent( - name="deferred repro (multi)", - instructions="Call both tools.", - model=ScriptedModel( - [ - ModelStep( - output=[ - function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), - function_call("write_other", {"query": "x"}, call_id="call_PARKED_2"), - ] - ), - ModelStep(output=[assistant_message("done")]), - ] - ), - tools=[write_thing, write_other], - output_guardrails=[always_fine], - tool_use_behavior=tool_use_behavior, +_EXPECTED_PAIR = ["function_call", "function_call_output"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_deferred_parked_call_is_persisted_when_the_resume_runs_again( + streamed: bool, +) -> None: + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + resumed = await _run(agent, state, session, streamed=streamed) + assert resumed.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + assert "pending_session_write" not in resumed.to_state().to_json() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_park_time_deferral_survives_a_tool_use_behavior_change_on_resume( + streamed: bool, +) -> None: + # The deferral decision is the checkpoint's, not the resuming configuration's: the + # caller resumes with the default behavior, and deriving the decision from the live + # gate would drop the parked call again. + session = SimpleListSession() + resume_agent = _make_deferring_agent(tool_use_behavior="run_llm_again") + state = await _parked_and_approved( + _make_deferring_agent(), session, streamed=streamed, resume_agent=resume_agent ) + await _run(resume_agent, state, session, streamed=streamed) + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + @pytest.mark.asyncio -async def test_partial_approval_reinterruption_persists_the_deferred_prefix() -> None: - """A resume that interrupts AGAIN must not strand the deferred calls. - - Two approval-required calls in one deferred response; the caller approves only one - and resumes with ``"run_llm_again"`` (gate closed). The resume resolves back into - ``NextStepInterruption``, and that re-interruption write is the deferred prefix's - last chance: it bumps the persisted count, so writing only the approved tool's - output there would orphan BOTH parked calls for every later resume. - """ +@pytest.mark.parametrize("streamed", [False, True]) +async def test_non_deferred_park_is_not_double_written_on_resume(streamed: bool) -> None: + # The other direction: with the default behavior throughout, the interruption-time + # write runs, so the resume must not write the parked call a second time. session = SimpleListSession() + agent = _make_deferring_agent(tool_use_behavior="run_llm_again") + state = await _parked_and_approved(agent, session, streamed=streamed) - first = Runner.run_streamed(make_multi_approval_agent(), "go", session=session) - async for _ in first.stream_events(): - pass - assert len(first.interruptions) == 2 + await _run(agent, state, session, streamed=streamed) + + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_partial_approval_reinterruption_keeps_one_canonical_batch( + streamed: bool, +) -> None: + # Two approval-required calls in one deferred response; approving only one resolves + # into a second interruption. The held batch must absorb the resolved output and + # keep riding as one record, then land exactly once when the run finally continues. + session = SimpleListSession() + agent = _make_multi_approval_agent() - resume_agent = make_multi_approval_agent(tool_use_behavior="run_llm_again") - serialized = json.dumps(first.to_state().to_json()) - state = await RunState.from_json(resume_agent, json.loads(serialized)) - first_approval = next( - interruption - for interruption in state.get_interruptions() - if "call_PARKED" == getattr(interruption.raw_item, "call_id", None) + first = await _run(agent, "go", session, streamed=streamed) + assert len(first.interruptions) == 2 + state = await _serialized_round_trip(first, agent) + state.approve( + next( + interruption + for interruption in state.get_interruptions() + if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" + ) ) - state.approve(first_approval) - second = Runner.run_streamed(resume_agent, state, session=session) - async for _ in second.stream_events(): - pass + second = await _run(agent, state, session, streamed=streamed) assert len(second.interruptions) == 1 - - serialized = json.dumps(second.to_state().to_json()) - state = await RunState.from_json(resume_agent, json.loads(serialized)) + second_checkpoint = second.to_state().to_json() + pending = second_checkpoint.get("pending_session_write") + assert pending is not None and pending.get("held") is True + assert {item.get("call_id") for item in pending["items"]} == { + "call_PARKED", + "call_PARKED_2", + } + + state = await RunState.from_json(agent, json.loads(json.dumps(second_checkpoint))) for interruption in state.get_interruptions(): state.approve(interruption) - final = Runner.run_streamed(resume_agent, state, session=session) - async for _ in final.stream_events(): - pass + final = await _run(agent, state, session, streamed=streamed) assert final.final_output == "done" items = await session.get_items() - call_ids = [item.get("call_id") for item in items if item.get("type") == "function_call"] - orphaned = [ - item - for item in items - if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids - ] - assert orphaned == [] - # Each parked call exactly once: recovered by the re-interruption write, and not - # written again by the later resumes (the persisted count now covers it). - assert call_ids.count("call_PARKED") == 1 - assert call_ids.count("call_PARKED_2") == 1 - - -def make_terminal_tool_agent(with_guardrails: bool = True) -> Agent: - """The approved tool IS terminal, so the resume ends in a final output.""" - return Agent( - name="deferred repro (terminal)", - instructions="Always call write_thing.", - model=ScriptedModel( - [ - ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), - ModelStep( - output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] - ), - ] - ), - tools=[look_up, write_thing], - output_guardrails=[always_fine] if with_guardrails else [], - tool_use_behavior=StopAtTools(stop_at_tool_names=["write_thing"]), - ) + assert _orphaned_outputs(items) == [] + assert _call_ids(items).count("call_PARKED") == 1 + assert _call_ids(items).count("call_PARKED_2") == 1 @pytest.mark.asyncio @pytest.mark.parametrize("resume_with_guardrails", [True, False]) -@pytest.mark.parametrize("streamed", [True, False]) +@pytest.mark.parametrize("streamed", [False, True]) async def test_deferred_prefix_reaches_a_resume_that_ends_in_final_output( resume_with_guardrails: bool, streamed: bool ) -> None: - """The final-output exit needs the prefix too, in both runners. - - ``_final_turn_items_for_persistence`` rebuilds the whole current response ONLY when - the agent has output guardrails; without them it returns the turn's items verbatim. - A resume may legitimately run without the guardrails the park had, and then the - deferred ``function_call`` was dropped on this exit. - """ + # A resume may legitimately run without the guardrails the park had; the + # final-output exit must land the held batch either way. session = SimpleListSession() + resume_agent = _make_terminal_tool_agent(with_guardrails=resume_with_guardrails) + state = await _parked_and_approved( + _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent + ) - async def go(agent: Agent, run_input: object) -> object: - if streamed: - result = Runner.run_streamed(agent, run_input, session=session) # type: ignore[arg-type] - async for _ in result.stream_events(): - pass - return result - return await Runner.run(agent, run_input, session=session) # type: ignore[arg-type] - - first = await go(make_terminal_tool_agent(), "do the thing") - assert len(first.interruptions) == 1 # type: ignore[attr-defined] - - resume_agent = make_terminal_tool_agent(with_guardrails=resume_with_guardrails) - serialized = json.dumps(first.to_state().to_json()) # type: ignore[attr-defined] - state = await RunState.from_json(resume_agent, json.loads(serialized)) - state.approve(state.get_interruptions()[0]) - await go(resume_agent, state) + await _run(resume_agent, state, session, streamed=streamed) items = await session.get_items() - call_ids = {item.get("call_id") for item in items if item.get("type") == "function_call"} - orphaned = [ - item - for item in items - if item.get("type") == "function_call_output" and item.get("call_id") not in call_ids - ] - assert orphaned == [] - assert "call_PARKED" in call_ids + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR @pytest.mark.asyncio async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session() -> None: - """``persisted_count`` can lie, so the prefix is confirmed against the Session. - - ``_validate_resumed_session_output_guardrail_safety`` resets the counter to zero for a - DETACHED resume ("a detached Session cannot contribute its old persisted prefix"). - That reset outlives the run, so a later resume reconnecting the original Session sees - zero and would rewrite items the Session already holds. - """ + # A non-deferred park persists the interrupted turn's items; the resumed-safety + # validation then zeroes the counter for a detached resume. A later resume that + # reconnects the original Session must not rewrite items it already holds. session = SimpleListSession() - parked = Runner.run_streamed( - make_multi_approval_agent(tool_use_behavior="run_llm_again"), "go", session=session + parked = await _run( + _make_multi_approval_agent(tool_use_behavior="run_llm_again"), + "go", + session, + streamed=True, ) - async for _ in parked.stream_events(): - pass assert len(parked.interruptions) == 2 - # No deferral at park time: the interrupted turn's items ARE persisted. - persisted_at_park = [item.get("call_id") for item in await session.get_items()] - assert "call_PARKED" in persisted_at_park + assert "call_PARKED" in _call_ids(await session.get_items()) - deferring_agent = make_multi_approval_agent() - state = await RunState.from_json( - deferring_agent, json.loads(json.dumps(parked.to_state().to_json())) - ) + deferring_agent = _make_multi_approval_agent() + state = await _serialized_round_trip(parked, deferring_agent) state.approve( next( interruption @@ -368,111 +435,231 @@ async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session( if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" ) ) - detached = Runner.run_streamed(deferring_agent, state, session=None) - async for _ in detached.stream_events(): - pass + detached = await _run(deferring_agent, state, None, streamed=True) - state = await RunState.from_json( - deferring_agent, json.loads(json.dumps(detached.to_state().to_json())) - ) + state = await _serialized_round_trip(detached, deferring_agent) for interruption in state.get_interruptions(): state.approve(interruption) - reconnected = Runner.run_streamed(deferring_agent, state, session=session) - async for _ in reconnected.stream_events(): - pass + await _run(deferring_agent, state, session, streamed=True) - call_ids = [ - item.get("call_id") - for item in await session.get_items() - if item.get("type") == "function_call" - ] + call_ids = _call_ids(await session.get_items()) assert call_ids.count("call_PARKED") == 1 assert call_ids.count("call_PARKED_2") == 1 -def make_emptying_handoff_agent() -> Agent: - """One response carrying the gated call AND a handoff whose filter empties the turn. +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner( + streamed: bool, +) -> None: + # When a handoff input_filter empties the resolved turn, the held batch must not be + # written on its own: a call with no output poisons the Session exactly as the + # orphaned output does. + session = SimpleListSession() + agent = _make_emptying_handoff_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + resumed = await _run(agent, state, session, streamed=streamed) - Resolving the approval then produces a turn with no session items at all: the shape - where a deferred prefix has nothing to ride on. - """ - from agents import HandoffInputData, handoff + items = await session.get_items() + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" + assert "pending_session_write" not in resumed.to_state().to_json() - def empties(data: HandoffInputData) -> HandoffInputData: - return HandoffInputData( - input_history=data.input_history, pre_handoff_items=(), new_items=() - ) - target = Agent( - name="target", - instructions="x", - model=ScriptedModel( - [ - ModelStep(output=[assistant_message("done")]), - ModelStep(output=[assistant_message("done")]), - ] - ), - ) - return Agent( - name="deferred repro (emptied turn)", - instructions="x", - model=ScriptedModel( - [ - ModelStep( - output=[ - function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), - function_call("transfer_to_target", {}, call_id="call_HANDOFF"), - ] - ), - ModelStep(output=[assistant_message("done")]), - ] - ), - tools=[write_thing], - handoffs=[handoff(target, input_filter=empties)], - output_guardrails=[always_fine], - tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]), - ) +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_settle_reaches_a_context_aware_session_through_the_wrapper( + streamed: bool, +) -> None: + session = _ContextRequiringSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + await _run(agent, state, session, streamed=streamed) + + assert session.wrapperless_operations == 0 + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_session_without_optional_kwargs_survives_a_deferred_resume( + streamed: bool, +) -> None: + session = _LegacyGetItemsSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + resumed = await _run(agent, state, session, streamed=streamed) + assert resumed.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +async def test_after_turn_cancel_keeps_the_held_batch_for_the_next_attach() -> None: + # The detached carry: a detached resume executes the approved tool, an after-turn + # cancel flips the checkpoint to a run-again step, and the batch must bring the + # executed output to the reattaching resume. Cancellation only exists on the + # streaming runner, so this scenario has no non-streamed axis. + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=True) + + detached = Runner.run_streamed(agent, state, session=None) + detached.cancel(mode="after_turn") + async for _ in detached.stream_events(): + pass + + checkpoint = detached.to_state().to_json() + pending = checkpoint.get("pending_session_write") + assert pending is not None and pending.get("held") is True + assert {item.get("call_id") for item in pending["items"]} >= {"call_PARKED"} + + state = await RunState.from_json(agent, json.loads(json.dumps(checkpoint))) + reattached = Runner.run_streamed(agent, state, session=session) + async for _ in reattached.stream_events(): + pass + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR @pytest.mark.asyncio -async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner() -> None: - """When the resolved turn has no session items, the deferred prefix must not be - written on its own: a call with no output poisons the Session exactly as the orphaned - output does. Both runners must also agree, item for item; a divergence here is how a - dangling-call regression would first show up. - """ - - async def run_case(streamed: bool) -> list[TResponseInputItem]: - session = SimpleListSession() - agent = make_emptying_handoff_agent() - first: RunResult | RunResultStreaming - if streamed: - first = Runner.run_streamed(agent, "go", session=session) - async for _ in first.stream_events(): +@pytest.mark.parametrize("streamed", [False, True]) +async def test_reject_persists_the_parked_call_with_its_rejection_output( + streamed: bool, +) -> None: + session = SimpleListSession() + agent = _make_deferring_agent() + first = await _run(agent, "do the thing", session, streamed=streamed) + assert len(first.interruptions) == 1 + state = await _serialized_round_trip(first, agent) + state.reject(state.get_interruptions()[0]) + + resumed = await _run(agent, state, session, streamed=streamed) + assert resumed.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +async def test_the_held_batch_rides_a_non_streamed_result_into_its_checkpoint() -> None: + # The non-streamed runner has no live RunState on a fresh park, so the declaration + # must ride the result into ``to_state``; dropping it there is the one silent way + # to lose the batch. + session = SimpleListSession() + agent = _make_deferring_agent() + + first = await Runner.run(agent, "do the thing", session=session) + assert len(first.interruptions) == 1 + + checkpoint = first.to_state().to_json() + pending = checkpoint.get("pending_session_write") + assert pending is not None and pending.get("held") is True + assert "call_PARKED" in {item.get("call_id") for item in pending["items"]} + assert pending.get("before") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_settled_batch_and_the_resolved_turn_land_as_one_ordered_write( + streamed: bool, +) -> None: + # Settling separately from the resolved turn's save would either trip the + # single-slot rule or advance the persisted count and slice the resolved items out + # of their own save, so the pair must land in one append, call before output. + session = _AppendRecordingSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + batches_before_resume = len(session.batches) + + await _run(agent, state, session, streamed=streamed) + + resume_batches = session.batches[batches_before_resume:] + settling_batches = [ + batch for batch in resume_batches if "call_PARKED" in {i.get("call_id") for i in batch} + ] + assert len(settling_batches) == 1 + assert _parked_pair(settling_batches[0]) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_tripwire_after_approval_keeps_the_sanitized_pair(streamed: bool) -> None: + session = SimpleListSession() + resume_agent = _make_terminal_tool_agent(tripping=True) + state = await _parked_and_approved( + _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent + ) + + if streamed: + resumed = Runner.run_streamed(resume_agent, state, session=session) + with pytest.raises(OutputGuardrailTripwireTriggered): + async for _ in resumed.stream_events(): pass - else: - first = await Runner.run(agent, "go", session=session) - assert len(first.interruptions) == 1 - serialized = json.dumps(first.to_state().to_json()) - state = await RunState.from_json(agent, json.loads(serialized)) - state.approve(state.get_interruptions()[0]) - if streamed: - resumed = Runner.run_streamed(agent, state, session=session) + else: + with pytest.raises(OutputGuardrailTripwireTriggered): + await Runner.run(resume_agent, state, session=session) + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_guardrail_crash_still_persists_the_parked_call(streamed: bool) -> None: + session = SimpleListSession() + resume_agent = _make_terminal_tool_agent(crashing=True) + state = await _parked_and_approved( + _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent + ) + + if streamed: + resumed = Runner.run_streamed(resume_agent, state, session=session) + with pytest.raises(RuntimeError, match="guardrail crashed"): async for _ in resumed.stream_events(): pass - else: - await Runner.run(agent, state, session=session) - return await session.get_items() + else: + with pytest.raises(RuntimeError, match="guardrail crashed"): + await Runner.run(resume_agent, state, session=session) - streamed_items = await run_case(streamed=True) - non_streamed_items = await run_case(streamed=False) + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR - for items in (streamed_items, non_streamed_items): - calls = {i.get("call_id") for i in items if i.get("type") == "function_call"} - outputs = {i.get("call_id") for i in items if i.get("type") == "function_call_output"} - assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" - assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" - assert [(i.get("type") or i.get("role"), i.get("call_id")) for i in streamed_items] == [ - (i.get("type") or i.get("role"), i.get("call_id")) for i in non_streamed_items - ] +@pytest.mark.asyncio +@pytest.mark.parametrize("retry_streamed", [False, True]) +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("round_trip", [False, True], ids=["live", "json"]) +@pytest.mark.parametrize("failure", ["before", "after"], ids=["atomic-failure", "lost-ack"]) +async def test_a_failed_settle_of_the_held_batch_is_recovered_on_the_next_resume( + retry_streamed: bool, streamed: bool, round_trip: bool, failure: str +) -> None: + session = _FailingResumeSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + session.failure = failure + with pytest.raises(RuntimeError) as error: + await _run(agent, state, session, streamed=streamed) + assert error.value is session.error + if round_trip: + state = await RunState.from_json(agent, state.to_json()) + + result = await _run(agent, state, session, streamed=retry_streamed) + assert result.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + assert "pending_session_write" not in result.to_state().to_json() diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 482a13edd8..870bba254e 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -499,7 +499,9 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( @pytest.mark.asyncio -@pytest.mark.parametrize("invalid", ["old-schema", "batch-shape"]) +@pytest.mark.parametrize( + "invalid", ["old-schema", "batch-shape", "held-shape", "held-with-before"] +) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: agent, _, session, state, _ = await _approved_session_state(False) session.failure = "before" @@ -508,12 +510,36 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval payload = state.to_json() if invalid == "old-schema": payload["$schemaVersion"] = "1.16" - else: + elif invalid == "batch-shape": payload["pending_session_write"]["items"] = "not an item batch" + elif invalid == "held-shape": + payload["pending_session_write"]["held"] = "yes" + else: + # A held batch was never offered to the Session, so recorded digests and the + # held marker cannot coexist on one record. + payload["pending_session_write"]["held"] = True with pytest.raises(UserError, match="pending Session write is invalid"): await RunState.from_json(agent, payload) +@pytest.mark.asyncio +async def test_pending_session_write_without_the_held_key_keeps_its_meaning() -> None: + # A checkpoint written before the held marker existed still settles eagerly on + # resume entry, exactly as released 1.17 behavior specified. + agent, model, session, state, effects = await _approved_session_state(False) + session.failure = "before" + with pytest.raises(RuntimeError): + await _run_session_resume(agent, state, session, False) + payload = state.to_json() + assert "held" not in payload["pending_session_write"] + restored = await RunState.from_json(agent, payload) + + result = await _run_session_resume(agent, restored, session, False) + assert result.final_output == "done" + assert effects == [7] + assert _charge_pair(await session.get_items()) == ["function_call", "function_call_output"] + + @pytest.mark.asyncio async def test_resumed_session_append_partial_commit_fails_closed() -> None: agent, model, session, state, effects = await _approved_session_state(False) From 86b5f7fd4dc4c5656e301f85a79e8ef24a1d165d Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:40:42 +0200 Subject: [PATCH 11/30] refactor(sessions): drop the dead no-state park bridge and tighten defer signature The non-streamed runner always builds a RunState for a fresh run, so the interruption result reads the held record from the state itself; the extra carrier parameter could never be exercised. --- src/agents/run.py | 6 ++-- .../run_internal/agent_runner_helpers.py | 11 ++---- .../run_internal/session_persistence.py | 25 ++++++-------- ...test_deferred_interrupted_session_write.py | 34 +++++++++++++++---- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index a9cf9a052b..b954974376 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -158,7 +158,7 @@ hydrate_tool_use_tracker, serialize_tool_use_tracker, ) -from .run_state import RunState, _PendingSessionWrite +from .run_state import RunState from .sandbox.memory.rollouts import terminal_metadata_for_exception from .sandbox.runtime import SandboxRuntime from .tool import dispose_resolved_computers @@ -2114,7 +2114,6 @@ async def _save_max_turns_handler_output( run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): - held_record: _PendingSessionWrite | None = None if session_persistence_enabled and not input_guardrails_triggered( _attempt_input_guardrail_results() ): @@ -2132,7 +2131,7 @@ async def _save_max_turns_handler_output( # guardrails decide; declaring the batch on the # checkpoint lets a resume settle it at a gate-legal # exit instead of losing it. - held_record = defer_interrupted_session_write( + defer_interrupted_session_write( run_state, session, input_items=input_items_for_save_interruption, @@ -2176,7 +2175,6 @@ async def _save_max_turns_handler_output( ) result = build_interruption_result( result_input=interruption_result_input2, - held_session_write=held_record, session_items=session_items, model_responses=model_responses, current_agent=current_agent, diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 21e7a4874b..674a6ff6a1 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -19,7 +19,7 @@ from ..result import RunResult from ..run_config import ReasoningItemIdPolicy, RunConfig from ..run_context import RunContextWrapper, TContext -from ..run_state import RunState, _PendingSessionWrite +from ..run_state import RunState from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult from ..tracing import Span from ..tracing.config import TracingConfig @@ -457,13 +457,8 @@ def build_interruption_result( generated_items: list[RunItem], run_state: RunState | None, original_input: str | list[TResponseInputItem], - held_session_write: _PendingSessionWrite | None = None, ) -> RunResult: - """Create a RunResult for an interruption path. - - ``held_session_write`` carries a held pending write registered by a park with no - live ``RunState``; with one, the record is read from the state itself. - """ + """Create a RunResult for an interruption path.""" identity_root_agent = ( run_state._starting_agent if run_state is not None and run_state._starting_agent is not None @@ -498,8 +493,6 @@ def build_interruption_result( # caller serializes ``result.to_state()``, which has no live ``RunState`` to # read the declaration from. result._pending_session_write = copy.deepcopy(run_state._pending_session_write) - elif held_session_write is not None: - result._pending_session_write = copy.deepcopy(held_session_write) result._original_input = copy_input_items(original_input) return result diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 60c8148d6b..accd945bc0 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -836,13 +836,13 @@ async def save_resumed_turn_items( def defer_interrupted_session_write( - run_state: RunState | None, + run_state: RunState, session: Session | None, *, input_items: Sequence[TResponseInputItem] | None = None, run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, -) -> _PendingSessionWrite | None: +) -> None: """Register the interruption's withheld batch as a held pending Session write. Registering is not writing: this touches only the checkpoint, never the Session, @@ -855,11 +855,11 @@ def defer_interrupted_session_write( Items are converted and deduplicated with the same helpers the real save uses, and the count is taken over the converted items: approval placeholders drop out in - conversion, so counting the raw run items would corrupt the persisted count. - Returns the record so a caller without a live ``RunState`` (a fresh non-streamed - park) can attach it to its result checkpoint. + conversion, so counting the raw run items would corrupt the persisted count. A + detached re-park has no Session and takes its ``session_id`` from the standing + declaration. """ - pending = run_state._pending_session_write if run_state is not None else None + pending = run_state._pending_session_write if pending is not None and not pending.get("held"): raise UserError("Resolve the pending Session write before saving another batch") @@ -888,7 +888,7 @@ def defer_interrupted_session_write( item for item in items if not _is_unpersistable_for_openai_conversation(item) ] if not items: - return None + return session_id = ( session.session_id @@ -896,20 +896,17 @@ def defer_interrupted_session_write( else (pending["session_id"] if pending is not None else None) ) if session_id is None: - return None + return record: _PendingSessionWrite = { "session_id": session_id, "items": copy.deepcopy(items), "before": None, "persisted_count": ( - run_state._current_turn_persisted_item_count if run_state is not None else 0 - ) - + len(converted_run_items), + run_state._current_turn_persisted_item_count + len(converted_run_items) + ), "held": True, } - if run_state is not None: - run_state._pending_session_write = record - return record + run_state._pending_session_write = record def extend_held_session_write( diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index fbbde0a1fd..ade421c985 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -113,8 +113,15 @@ def _make_multi_approval_agent( ) +_PREAMBLE_TEXT = "About to write the thing." + + def _make_terminal_tool_agent( - *, with_guardrails: bool = True, tripping: bool = False, crashing: bool = False + *, + with_guardrails: bool = True, + tripping: bool = False, + crashing: bool = False, + with_preamble: bool = False, ) -> Agent: """The approved tool is terminal, so the resume ends in a final output.""" guardrails = [always_fine] @@ -122,15 +129,16 @@ def _make_terminal_tool_agent( guardrails = [always_trips] if crashing: guardrails = [always_crashes] + parked_response = [function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + if with_preamble: + parked_response = [assistant_message(_PREAMBLE_TEXT), *parked_response] return Agent( name="deferred repro (terminal)", instructions="Always call write_thing.", model=ScriptedModel( [ ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), - ModelStep( - output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] - ), + ModelStep(output=parked_response), ] ), tools=[look_up, write_thing], @@ -387,6 +395,10 @@ async def test_partial_approval_reinterruption_keeps_one_canonical_batch( assert _orphaned_outputs(items) == [] assert _call_ids(items).count("call_PARKED") == 1 assert _call_ids(items).count("call_PARKED_2") == 1 + # Every call must also keep its output: losing the first approval's output while + # the batch rides the second park is the symmetric corruption. + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert set(_call_ids(items)) == outputs @pytest.mark.asyncio @@ -595,9 +607,12 @@ async def test_the_settled_batch_and_the_resolved_turn_land_as_one_ordered_write @pytest.mark.parametrize("streamed", [False, True]) async def test_a_tripwire_after_approval_keeps_the_sanitized_pair(streamed: bool) -> None: session = SimpleListSession() - resume_agent = _make_terminal_tool_agent(tripping=True) + resume_agent = _make_terminal_tool_agent(tripping=True, with_preamble=True) state = await _parked_and_approved( - _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent + _make_terminal_tool_agent(with_preamble=True), + session, + streamed=streamed, + resume_agent=resume_agent, ) if streamed: @@ -605,6 +620,9 @@ async def test_a_tripwire_after_approval_keeps_the_sanitized_pair(streamed: bool with pytest.raises(OutputGuardrailTripwireTriggered): async for _ in resumed.stream_events(): pass + # The declaration is discarded when the blocked outcome is decided; a record + # that outlives the tripwire would invalidate the run's checkpoint. + assert "pending_session_write" not in resumed.to_state().to_json() else: with pytest.raises(OutputGuardrailTripwireTriggered): await Runner.run(resume_agent, state, session=session) @@ -612,6 +630,9 @@ async def test_a_tripwire_after_approval_keeps_the_sanitized_pair(streamed: bool items = await session.get_items() assert _orphaned_outputs(items) == [] assert _parked_pair(items) == _EXPECTED_PAIR + # The redaction drops the blocked response's preamble; feeding the raw held batch + # into the blocked save would resurrect it. + assert not any(_PREAMBLE_TEXT in json.dumps(item) for item in items) @pytest.mark.asyncio @@ -628,6 +649,7 @@ async def test_a_guardrail_crash_still_persists_the_parked_call(streamed: bool) with pytest.raises(RuntimeError, match="guardrail crashed"): async for _ in resumed.stream_events(): pass + assert "pending_session_write" not in resumed.to_state().to_json() else: with pytest.raises(RuntimeError, match="guardrail crashed"): await Runner.run(resume_agent, state, session=session) From b95d5676f8476ae5f8dc9500659d7f5fb475882e Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:41:47 +0200 Subject: [PATCH 12/30] test: pin that a settled or discarded batch never lingers on the live state --- tests/test_deferred_interrupted_session_write.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index ade421c985..506a564487 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -478,6 +478,9 @@ async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner( assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" assert "pending_session_write" not in resumed.to_state().to_json() + # The discard must reach the live state too: a stale held record would invalidate + # any checkpoint later taken from this completed run. + assert state._pending_session_write is None @pytest.mark.asyncio @@ -653,6 +656,8 @@ async def test_a_guardrail_crash_still_persists_the_parked_call(streamed: bool) else: with pytest.raises(RuntimeError, match="guardrail crashed"): await Runner.run(resume_agent, state, session=session) + # The crash-path save claims the batch, so no stale record survives on the state. + assert state._pending_session_write is None items = await session.get_items() assert _orphaned_outputs(items) == [] From 529b9304dbd1e2bd5a0ba7f391e9edac44ee1c8a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:42:44 +0200 Subject: [PATCH 13/30] style: apply ruff formatting to the touched files --- src/agents/run_internal/run_loop.py | 4 +--- src/agents/run_internal/session_persistence.py | 4 +--- tests/test_deferred_interrupted_session_write.py | 4 +++- tests/test_run_impl_resume_paths.py | 4 +--- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 62a4de420f..47de02235f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -2038,9 +2038,7 @@ def _record_max_turns_handler_output( run_state, session, run_items=turn_session_items, - reasoning_item_id_policy=( - streamed_result._reasoning_item_id_policy - ), + reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), ) await _finalize_streamed_interruption( streamed_result=streamed_result, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index accd945bc0..b40e0162c0 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -884,9 +884,7 @@ def defer_interrupted_session_write( ) if isinstance(session, OpenAIConversationsSession): items = [_sanitize_openai_conversation_item(item) for item in items] - items = [ - item for item in items if not _is_unpersistable_for_openai_conversation(item) - ] + items = [item for item in items if not _is_unpersistable_for_openai_conversation(item)] if not items: return diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 506a564487..e1cb7c4457 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -152,7 +152,9 @@ def _make_emptying_handoff_agent() -> Agent: from agents import HandoffInputData, handoff def empties(data: HandoffInputData) -> HandoffInputData: - return HandoffInputData(input_history=data.input_history, pre_handoff_items=(), new_items=()) + return HandoffInputData( + input_history=data.input_history, pre_handoff_items=(), new_items=() + ) target = Agent( name="target", diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 870bba254e..d872a206e2 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -499,9 +499,7 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( @pytest.mark.asyncio -@pytest.mark.parametrize( - "invalid", ["old-schema", "batch-shape", "held-shape", "held-with-before"] -) +@pytest.mark.parametrize("invalid", ["old-schema", "batch-shape", "held-shape", "held-with-before"]) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: agent, _, session, state, _ = await _approved_session_state(False) session.failure = "before" From f31635aade93b417d770b8dae4deb1756d42c18b Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 11:48:56 +0200 Subject: [PATCH 14/30] fix(sessions): narrow the fresh park registration for the type checker --- src/agents/run.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index b954974376..5be95b5d9e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -2123,14 +2123,18 @@ async def _save_max_turns_handler_output( if session_input_items_for_persistence is not None else [] ) - if _should_defer_interrupted_session_items( - current_agent, - run_config, + if run_state is not None and ( + _should_defer_interrupted_session_items( + current_agent, + run_config, + ) ): # The gate withholds this write until the output # guardrails decide; declaring the batch on the # checkpoint lets a resume settle it at a gate-legal - # exit instead of losing it. + # exit instead of losing it. This runner always + # builds a RunState, so the narrowing never skips a + # real park. defer_interrupted_session_write( run_state, session, @@ -2138,8 +2142,6 @@ async def _save_max_turns_handler_output( run_items=session_items_for_turn(turn_result), reasoning_item_id_policy=( run_state._reasoning_item_id_policy - if run_state is not None - else None ), ) else: From f8785fd177e87bf0fb54066cffab846cea1c85e5 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 13:46:39 +0200 Subject: [PATCH 15/30] fix(sessions): validate the held resume's Session and settle only paired held calls A held checkpoint resumed against a different Session must fail at boot like an ordinary pending write, before the approved tool can execute and the batch can settle into the wrong conversation. And a handoff input_filter may drop a subset of the resolved outputs, so the settling batch being non-empty does not make it safe: a held call settles only when its output survived, which honors the filter's decision symmetrically in both directions. --- .../run_internal/session_persistence.py | 55 ++++++++- ...test_deferred_interrupted_session_write.py | 105 +++++++++++++++++- 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index b40e0162c0..25acc88b3f 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -816,6 +816,8 @@ async def save_resumed_turn_items( """ if session is None or (not items and not held_input): return persisted_count + if held_input: + held_input = _held_items_safe_to_settle(held_input, items, reasoning_item_id_policy) saved_count = await save_result_to_session( session, list(held_input) if held_input else [], @@ -835,6 +837,40 @@ async def save_resumed_turn_items( return persisted_count + saved_count +def _held_items_safe_to_settle( + held_items: list[TResponseInputItem], + run_items: Sequence[RunItem], + reasoning_item_id_policy: ReasoningItemIdPolicy | None, +) -> list[TResponseInputItem]: + """Drop held calls whose outputs did not survive into the settling batch. + + A handoff ``input_filter`` may drop some resolved outputs while keeping others, so + the settling batch being non-empty does not make it safe: a held ``function_call`` + settled without its output poisons the Session exactly as the orphaned output does. + Pairing is the safety predicate. The paired part of the batch still settles, which + honors the filter's decision symmetrically: a dropped output takes its call with it, + and a kept output keeps its call. + """ + output_ids = { + item.get("call_id") + for item in held_items + if isinstance(item, dict) and item.get("type") == "function_call_output" + } + for run_item in run_items: + converted = run_item_to_input_item(run_item, reasoning_item_id_policy) + if isinstance(converted, dict) and converted.get("type") == "function_call_output": + output_ids.add(converted.get("call_id")) + return [ + item + for item in held_items + if not ( + isinstance(item, dict) + and item.get("type") == "function_call" + and item.get("call_id") not in output_ids + ) + ] + + def defer_interrupted_session_write( run_state: RunState, session: Session | None, @@ -969,12 +1005,19 @@ async def resume_pending_session_write( # A held batch is the write the interruption park withheld under the # output-guardrail gate, and resume entry is not a gate-legal settle point, so # the declaration rides the checkpoint untouched; in particular a detached - # resume must not fail the boot over a batch it cannot settle. The exception - # is a run-again checkpoint: the parked response's outputs already went back - # to the model, which only happens after the gate stopped applying to that - # response, so the batch settles here, before the next model call. This is - # also the only settle point such a checkpoint will ever reach, because the - # run-again turn's saves never arm ``resumed_write_state``. + # resume must not fail the boot over a batch it cannot settle. An attached + # Session must still be the declared one, and must fail here at boot: letting + # the run proceed would execute the approved tool and settle the batch into + # the wrong conversation. The exception to riding is a run-again checkpoint: + # the parked response's outputs already went back to the model, which only + # happens after the gate stopped applying to that response, so the batch + # settles here, before the next model call. This is also the only settle + # point such a checkpoint will ever reach, because the run-again turn's saves + # never arm ``resumed_write_state``. + if session is not None and session.session_id != pending["session_id"]: + raise UserError( + "Resume the pending Session write with the original Session and session ID" + ) if session is None or not isinstance(run_state._current_step, NextStepRunAgain): return pending.pop("held", None) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index e1cb7c4457..fca6545f13 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -152,9 +152,7 @@ def _make_emptying_handoff_agent() -> Agent: from agents import HandoffInputData, handoff def empties(data: HandoffInputData) -> HandoffInputData: - return HandoffInputData( - input_history=data.input_history, pre_handoff_items=(), new_items=() - ) + return HandoffInputData(input_history=data.input_history, pre_handoff_items=(), new_items=()) target = Agent( name="target", @@ -187,6 +185,61 @@ def empties(data: HandoffInputData) -> HandoffInputData: ) +def _make_partial_filter_handoff_agent() -> Agent: + """Two gated calls plus a handoff whose filter drops exactly one resolved output. + + An ``input_filter`` is an arbitrary caller callable, so dropping a subset of the + resolved outputs is a legitimate shape; the held batch must not settle a call whose + output the filter took away. + """ + from agents import HandoffInputData, handoff + + def drops_one_output(data: HandoffInputData) -> HandoffInputData: + def keep(items: tuple) -> tuple: + kept = [] + for item in items: + raw = getattr(item, "raw_item", None) + call_id = ( + raw.get("call_id") if isinstance(raw, dict) else getattr(raw, "call_id", None) + ) + if call_id == "call_PARKED_2" and item.type == "tool_call_output_item": + continue + kept.append(item) + return tuple(kept) + + return HandoffInputData( + input_history=data.input_history, + pre_handoff_items=keep(data.pre_handoff_items), + new_items=keep(data.new_items), + ) + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + return Agent( + name="deferred repro (partial filter)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("write_other", {"query": "x"}, call_id="call_PARKED_2"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing, write_other], + handoffs=[handoff(target, input_filter=drops_one_output)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + class _ContextRequiringSession(SimpleListSession): """Track whether internal reads and writes carry the run's context wrapper.""" @@ -485,6 +538,52 @@ async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner( assert state._pending_session_write is None +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_filter_that_drops_one_output_takes_its_held_call_with_it( + streamed: bool, +) -> None: + # The resolved turn is non-empty (one output survived the filter), so batch + # emptiness is the wrong safety predicate: settling the whole held batch would land + # the filtered call dangling, and discarding the whole batch would orphan the + # output the filter kept. Pairing is the contract, per call. + session = SimpleListSession() + agent = _make_partial_filter_handoff_agent() + first = await _run(agent, "go", session, streamed=streamed) + state = await _serialized_round_trip(first, agent) + for interruption in state.get_interruptions(): + state.approve(interruption) + resumed = await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" + assert "call_PARKED" in calls + assert "pending_session_write" not in resumed.to_state().to_json() + assert state._pending_session_write is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_held_resume_with_a_different_session_is_refused(streamed: bool) -> None: + # The held entry skip must not bypass the same-session contract: resuming the + # approval checkpoint against another Session would execute the tool and settle the + # withheld batch into the wrong conversation. + from agents.exceptions import UserError + + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + other_session = SimpleListSession("other") + with pytest.raises(UserError, match="pending Session write"): + await _run(agent, state, other_session, streamed=streamed) + + assert await other_session.get_items() == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) async def test_settle_reaches_a_context_aware_session_through_the_wrapper( From 546a3b4237236f377e67db373de8b5f0f2a1a265 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 13:48:34 +0200 Subject: [PATCH 16/30] style: satisfy line length and the settling batch parameter type --- src/agents/run_internal/session_persistence.py | 2 +- tests/test_deferred_interrupted_session_write.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 25acc88b3f..d5f05b3bcd 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -838,7 +838,7 @@ async def save_resumed_turn_items( def _held_items_safe_to_settle( - held_items: list[TResponseInputItem], + held_items: Sequence[TResponseInputItem], run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None, ) -> list[TResponseInputItem]: diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index fca6545f13..f6021273d1 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -152,7 +152,9 @@ def _make_emptying_handoff_agent() -> Agent: from agents import HandoffInputData, handoff def empties(data: HandoffInputData) -> HandoffInputData: - return HandoffInputData(input_history=data.input_history, pre_handoff_items=(), new_items=()) + return HandoffInputData( + input_history=data.input_history, pre_handoff_items=(), new_items=() + ) target = Agent( name="target", From d7bbe1a6fb335ade04f19dd8cd3d89a5dc757746 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 14:27:37 +0200 Subject: [PATCH 17/30] fix(sessions): carry the held batch through detached parks, pending approvals, and completions Four holes the adversarial pass over the final diff surfaced, each reproduced in both runners before fixing. A fresh park during a detached resume now folds the new call into the standing held batch instead of losing it. The pairing guard exempts calls whose approvals are still open on the current step: their outputs are missing because they have not run yet, not because a filter removed them, so they settle like a non-deferred park writes a call before its output. The entry settle applies the same pairing contract against the batch alone. And a detached completion discards the batch at the fresh final exit too, so a completed run's checkpoint stays loadable and the runners agree. The streamed resume test double now forwards the settling batch. --- src/agents/run.py | 16 +++ src/agents/run_internal/run_loop.py | 18 ++- .../run_internal/session_persistence.py | 36 ++++- tests/test_agent_runner_streamed.py | 2 + ...test_deferred_interrupted_session_write.py | 131 ++++++++++++++++++ 5 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 5be95b5d9e..3573f34f30 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1954,6 +1954,12 @@ async def _save_max_turns_handler_output( try: if isinstance(turn_result.next_step, NextStepFinalOutput): + if session is None and run_state is not None: + # A detached completion has no Session to settle against + # and the run ends here, so the batch is discarded + # rather than left to invalidate the completed run's + # checkpoint. Mirrors the resumed final exit. + take_held_session_write(run_state) if run_state is not None and _has_output_guardrails( current_agent, run_config ): @@ -2154,6 +2160,16 @@ async def _save_max_turns_handler_output( store=store_setting, wrapper=context_wrapper, ) + elif session is None and run_state is not None: + # A fresh park during a detached resume cannot write, + # but a standing held declaration carries the session + # identity: the new parked call folds into it so the + # reattach does not settle its output orphaned. + extend_held_session_write( + run_state, + run_items=session_items_for_turn(turn_result), + reasoning_item_id_policy=(run_state._reasoning_item_id_policy), + ) append_model_response_if_new( model_responses, turn_result.model_response ) diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 47de02235f..a51d10c79f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1988,6 +1988,12 @@ def _record_max_turns_handler_output( if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result): break elif isinstance(turn_result.next_step, NextStepFinalOutput): + if session is None: + # A detached completion has no Session to settle against and + # the run ends here, so the batch is discarded rather than + # left to invalidate the completed run's checkpoint. Mirrors + # the resumed final exit. + take_held_session_write(run_state) await _finalize_streamed_final_output( streamed_result=streamed_result, agent=current_agent, @@ -2026,7 +2032,17 @@ def _record_max_turns_handler_output( current_agent, run_config, ) - if parked_items_deferred and await _should_persist_stream_items( + if session is None: + # A fresh park during a detached resume cannot write, but a + # standing held declaration carries the session identity: the + # new parked call folds into it so the reattach does not + # settle its output orphaned. + extend_held_session_write( + run_state, + run_items=turn_session_items, + reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), + ) + elif parked_items_deferred and await _should_persist_stream_items( session=session, server_conversation_tracker=server_conversation_tracker, streamed_result=streamed_result, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index d5f05b3bcd..eda104adcd 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -817,7 +817,12 @@ async def save_resumed_turn_items( if session is None or (not items and not held_input): return persisted_count if held_input: - held_input = _held_items_safe_to_settle(held_input, items, reasoning_item_id_policy) + held_input = _held_items_safe_to_settle( + held_input, + items, + reasoning_item_id_policy, + pending_call_ids=_pending_approval_call_ids(run_state), + ) saved_count = await save_result_to_session( session, list(held_input) if held_input else [], @@ -837,10 +842,25 @@ async def save_resumed_turn_items( return persisted_count + saved_count +def _pending_approval_call_ids(run_state: RunState | None) -> set[str]: + """Return the call ids still awaiting approval on the state's current step.""" + if run_state is None or not isinstance(run_state._current_step, NextStepInterruption): + return set() + ids: set[str] = set() + for approval in run_state._current_step.interruptions: + raw = getattr(approval, "raw_item", None) + call_id = raw.get("call_id") if isinstance(raw, dict) else getattr(raw, "call_id", None) + if isinstance(call_id, str) and call_id: + ids.add(call_id) + return ids + + def _held_items_safe_to_settle( held_items: Sequence[TResponseInputItem], run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None, + *, + pending_call_ids: set[str] | None = None, ) -> list[TResponseInputItem]: """Drop held calls whose outputs did not survive into the settling batch. @@ -850,12 +870,19 @@ def _held_items_safe_to_settle( Pairing is the safety predicate. The paired part of the batch still settles, which honors the filter's decision symmetrically: a dropped output takes its call with it, and a kept output keeps its call. + + ``pending_call_ids`` names calls whose approvals are still open on the current + step: their outputs are missing because they have not run yet, not because a + filter removed them, so they settle now and pair up at a later exit, exactly as a + non-deferred park persists a call before its output exists. """ output_ids = { item.get("call_id") for item in held_items if isinstance(item, dict) and item.get("type") == "function_call_output" } + if pending_call_ids: + output_ids |= pending_call_ids for run_item in run_items: converted = run_item_to_input_item(run_item, reasoning_item_id_policy) if isinstance(converted, dict) and converted.get("type") == "function_call_output": @@ -1020,6 +1047,13 @@ async def resume_pending_session_write( ) if session is None or not isinstance(run_state._current_step, NextStepRunAgain): return + # The entry settle offers the batch with no accompanying resolved items, so the + # pairing contract applies against the batch alone: a call whose output a + # detached handoff filter dropped must not land dangling here either. + pending["items"] = _held_items_safe_to_settle(pending["items"], [], None) + if not pending["items"]: + run_state._pending_session_write = None + return pending.pop("held", None) if run_state._session_write_in_progress: raise UserError("The pending Session write is already in progress for this RunState") diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 2923286924..8348b3ce1e 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4318,6 +4318,7 @@ async def save_wrapper( store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, run_state: RunState | None = None, + held_input: Any = None, ) -> int: observed_counts.append(persisted_count) result = await real_save_resumed( @@ -4329,6 +4330,7 @@ async def save_wrapper( store=store, wrapper=wrapper, run_state=run_state, + held_input=held_input, ) return int(result) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index f6021273d1..d6d1b6f3c1 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -793,3 +793,134 @@ async def test_a_failed_settle_of_the_held_batch_is_recovered_on_the_next_resume assert _orphaned_outputs(items) == [] assert _parked_pair(items) == _EXPECTED_PAIR assert "pending_session_write" not in result.to_state().to_json() + + +def _make_two_park_agent() -> Agent: + """Two approval-required calls on consecutive turns, so a resume can park again.""" + return Agent( + name="deferred repro (two parks)", + instructions="x", + model=ScriptedModel( + [ + ModelStep(output=[function_call("write_thing", {"query": "a"}, call_id="call_A")]), + ModelStep(output=[function_call("write_other", {"query": "b"}, call_id="call_B")]), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[write_thing, write_other], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_new_park_during_a_detached_resume_joins_the_held_batch( + streamed: bool, +) -> None: + # A detached resume resolves the first approval and parks a second call on the next + # turn. That fresh park cannot write anything, but the standing declaration carries + # the session identity, so the new call must fold into the held batch or the + # reattach settles its output orphaned. + session = SimpleListSession() + agent = _make_two_park_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + detached = await _run(agent, state, None, streamed=streamed) + assert len(detached.interruptions) == 1 + state = await _serialized_round_trip(detached, agent) + state.approve(state.get_interruptions()[0]) + + reattached = await _run(agent, state, session, streamed=streamed) + assert reattached.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert calls == outputs + assert {"call_A", "call_B"} <= calls + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_gate_off_reinterruption_keeps_the_still_pending_call(streamed: bool) -> None: + # Approving one of two held calls and resuming with the default behavior turns the + # gate off, so the re-interruption exit settles the batch mid-run. The unapproved + # call's output does not exist yet because it is still pending, not because a + # filter removed it; dropping it there orphans its output on the final resume. + session = SimpleListSession() + resume_agent = _make_multi_approval_agent(tool_use_behavior="run_llm_again") + + first = await _run(_make_multi_approval_agent(), "go", session, streamed=streamed) + assert len(first.interruptions) == 2 + state = await _serialized_round_trip(first, resume_agent) + state.approve( + next( + interruption + for interruption in state.get_interruptions() + if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" + ) + ) + + second = await _run(resume_agent, state, session, streamed=streamed) + assert len(second.interruptions) == 1 + state = await _serialized_round_trip(second, resume_agent) + for interruption in state.get_interruptions(): + state.approve(interruption) + final = await _run(resume_agent, state, session, streamed=streamed) + assert final.final_output == "done" + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert calls == outputs + assert {"call_PARKED", "call_PARKED_2"} <= calls + + +@pytest.mark.asyncio +async def test_entry_settle_drops_a_held_call_the_filter_unpaired() -> None: + # A detached resume of the partial-filter handoff folds the post-filter items into + # the batch, the handoff normalizes the checkpoint to run-again, and an after-turn + # cancellation stops the run there. The reattach settles at entry, where the same + # pairing contract applies: the filtered call must not land dangling. Cancellation + # only exists on the streaming runner, and this checkpoint shape resumes from the + # live state. + session = SimpleListSession() + agent = _make_partial_filter_handoff_agent() + first = await _run(agent, "go", session, streamed=True) + state = await _serialized_round_trip(first, agent) + for interruption in state.get_interruptions(): + state.approve(interruption) + + detached = Runner.run_streamed(agent, state, session=None) + detached.cancel(mode="after_turn") + async for _ in detached.stream_events(): + pass + + reattached = Runner.run_streamed(agent, detached.to_state(), session=session) + async for _ in reattached.stream_events(): + pass + + items = await session.get_items() + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_detached_completion_clears_the_held_record(streamed: bool) -> None: + # A detached resume that runs to completion has no Session to settle against and + # the fresh final exit ends the run; a held record left standing would invalidate + # the completed run's checkpoint and diverge between the runners. + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + detached = await _run(agent, state, None, streamed=streamed) + assert detached.final_output == "done" + assert "pending_session_write" not in detached.to_state().to_json() + assert state._pending_session_write is None From d4d70d9063cb5b0d74ce92f7b4c634dc05dc3c88 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 14:44:31 +0200 Subject: [PATCH 18/30] fix(sessions): keep run input out of the held batch, register final settles, and restore Conversations sanitization at entry Three findings from the third automated review round. The sandbox runtime defers the pre-turn input save, so a deferred park used to fold the Session's only copy of the accepted input into the held batch, where a tripwire discard would take it along: the deferred arm now persists any unsaved input exactly as the non-deferred arm does, and the batch carries only the withheld response. The final-output settle now registers the claimed batch before appending, so a crash inside that append fails closed with the batch recorded instead of silently losing it. And the attached entry settle re-applies the Conversations-specific sanitization a detached extension could not, restoring the backend invariant before the direct append. --- src/agents/run.py | 26 +++-- .../run_internal/agent_runner_helpers.py | 4 + .../run_internal/session_persistence.py | 38 +++--- ...test_deferred_interrupted_session_write.py | 108 ++++++++++++++++++ 4 files changed, 155 insertions(+), 21 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 3573f34f30..1888a40f8a 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -2135,16 +2135,28 @@ async def _save_max_turns_handler_output( run_config, ) ): - # The gate withholds this write until the output - # guardrails decide; declaring the batch on the - # checkpoint lets a resume settle it at a gate-legal - # exit instead of losing it. This runner always - # builds a RunState, so the narrowing never skips a - # real park. + # The gate withholds the interrupted response, not + # the user's accepted input: any input still + # unsaved (the sandbox runtime defers the pre-turn + # save) persists here exactly as the non-deferred + # arm would, so the held batch never carries the + # Session's only copy of the input. Declaring the + # response batch on the checkpoint lets a resume + # settle it at a gate-legal exit instead of losing + # it. This runner always builds a RunState, so the + # narrowing never skips a real park. + if input_items_for_save_interruption: + await save_result_to_session( + session, + input_items_for_save_interruption, + [], + run_state, + store=store_setting, + wrapper=context_wrapper, + ) defer_interrupted_session_write( run_state, session, - input_items=input_items_for_save_interruption, run_items=session_items_for_turn(turn_result), reasoning_item_id_policy=( run_state._reasoning_item_id_policy diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 674a6ff6a1..e0887e05d0 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -608,6 +608,7 @@ async def save_final_turn_items_after_guardrails( reasoning_item_id_policy=run_state._reasoning_item_id_policy, store=store, wrapper=wrapper, + run_state=run_state, held_input=held_input, ) return run_state._current_turn_persisted_item_count @@ -620,6 +621,9 @@ async def save_final_turn_items_after_guardrails( reasoning_item_id_policy=reasoning_item_id_policy, store=store, wrapper=wrapper, + # A settling held batch always registers, so a crash inside this append fails + # closed with the batch recorded instead of silently losing it. + resumed_write_state=run_state if held_input else None, ) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index eda104adcd..0618035b4c 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -835,7 +835,13 @@ async def save_resumed_turn_items( resumed_write_state=( run_state if run_state is not None - and isinstance(run_state._current_step, NextStepRunAgain | NextStepInterruption) + and ( + isinstance(run_state._current_step, NextStepRunAgain | NextStepInterruption) + # A settling held batch always registers, so a crash inside the append + # fails closed with the batch recorded instead of silently losing the + # only copy of an approved tool's call and output. + or bool(held_input) + ) else None ), ) @@ -902,7 +908,6 @@ def defer_interrupted_session_write( run_state: RunState, session: Session | None, *, - input_items: Sequence[TResponseInputItem] | None = None, run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, ) -> None: @@ -921,19 +926,15 @@ def defer_interrupted_session_write( conversion, so counting the raw run items would corrupt the persisted count. A detached re-park has no Session and takes its ``session_id`` from the standing declaration. + + The batch carries only the withheld response: run input is never registered here, + because the gate withholds model output, not the user's accepted input, and a + tripwire discards the batch without inspecting it. """ pending = run_state._pending_session_write if pending is not None and not pending.get("held"): raise UserError("Resolve the pending Session write before saving another batch") - converted_input: list[TResponseInputItem] = [] - if input_items: - converted_input = normalize_input_items_for_api( - [ - ensure_input_item_format(item) - for item in ItemHelpers.input_to_new_input_list(list(input_items)) - ] - ) converted_run_items: list[TResponseInputItem] = [] for run_item in run_items: as_input = run_item_to_input_item(run_item, reasoning_item_id_policy) @@ -942,9 +943,7 @@ def defer_interrupted_session_write( converted_run_items.append(ensure_input_item_format(as_input)) base_items = list(pending["items"]) if pending is not None else [] - items = deduplicate_input_items_preferring_latest( - base_items + converted_input + converted_run_items - ) + items = deduplicate_input_items_preferring_latest(base_items + converted_run_items) if isinstance(session, OpenAIConversationsSession): items = [_sanitize_openai_conversation_item(item) for item in items] items = [item for item in items if not _is_unpersistable_for_openai_conversation(item)] @@ -1049,7 +1048,18 @@ async def resume_pending_session_write( return # The entry settle offers the batch with no accompanying resolved items, so the # pairing contract applies against the batch alone: a call whose output a - # detached handoff filter dropped must not land dangling here either. + # detached handoff filter dropped must not land dangling here either. A batch + # extended while detached also missed the Conversations-specific sanitization, + # so the attached backend's invariant is restored before the direct append. + if isinstance(session, OpenAIConversationsSession): + pending["items"] = [ + _sanitize_openai_conversation_item(item) for item in pending["items"] + ] + pending["items"] = [ + item + for item in pending["items"] + if not _is_unpersistable_for_openai_conversation(item) + ] pending["items"] = _held_items_safe_to_settle(pending["items"], [], None) if not pending["items"]: run_state._pending_session_write = None diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index d6d1b6f3c1..74ee7c1c64 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -684,6 +684,10 @@ async def test_the_held_batch_rides_a_non_streamed_result_into_its_checkpoint() assert pending is not None and pending.get("held") is True assert "call_PARKED" in {item.get("call_id") for item in pending["items"]} assert pending.get("before") is None + # The batch carries only the withheld response: the accepted input persists + # eagerly even at a deferred park, so a tripwire discard can never take the + # Session's only copy of the input with it. + assert not any(item.get("role") == "user" for item in pending["items"]) @pytest.mark.asyncio @@ -924,3 +928,107 @@ async def test_a_detached_completion_clears_the_held_record(streamed: bool) -> N assert detached.final_output == "done" assert "pending_session_write" not in detached.to_state().to_json() assert state._pending_session_write is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_failed_final_settle_fails_closed_with_the_batch_recorded( + streamed: bool, +) -> None: + # The final-output settle registers the claimed batch before appending, so a crash + # inside that append leaves the batch recorded on the state instead of silently + # losing the only copy of the approved call and its output. The resulting + # checkpoint is rejected on load on purpose: the run ended mid-settle, and failing + # closed beats replaying an approved side effect as if nothing happened. + session = _FailingResumeSession() + resume_agent = _make_terminal_tool_agent() + state = await _parked_and_approved( + _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent + ) + + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run(resume_agent, state, session, streamed=streamed) + + pending = state._pending_session_write + assert pending is not None + recorded = {item.get("call_id") for item in pending["items"]} + assert "call_PARKED" in recorded + with pytest.raises(Exception, match="pending Session write"): + await RunState.from_json(resume_agent, state.to_json()) + + +class _RecordingConversationsSession: + """Stand-in with the Conversations class identity, at the boundary the settle checks. + + The real ``OpenAIConversationsSession`` talks to the Conversations API; the settle + only consults its class via ``isinstance`` to decide whether the batch needs the + Conversations sanitization, so the fake records what would be sent instead. + """ + + def __new__(cls) -> _RecordingConversationsSession: + from agents.memory.openai_conversations_session import OpenAIConversationsSession + + instance = object.__new__( + type("_FakeConversations", (OpenAIConversationsSession,), dict(cls.__dict__)) + ) + instance.session_id = "conv-1" + instance.added: list[TResponseInputItem] = [] + return instance + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + return [] + + async def add_items(self, items: list[TResponseInputItem]) -> None: + self.added.extend(items) + + async def pop_item(self) -> TResponseInputItem | None: + return None + + async def clear_session(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_entry_settle_restores_the_conversations_sanitization() -> None: + # A batch extended while detached missed the Conversations-specific sanitization; + # the attached entry settle must restore it or the create-items request rejects + # stale provider ids the normal persistence path strips. + from agents.run_internal.run_steps import NextStepRunAgain + from agents.run_internal.session_persistence import resume_pending_session_write + + session = _RecordingConversationsSession() + state = RunState( + context=None, + original_input="go", + starting_agent=_make_deferring_agent(), + max_turns=5, + ) + state._current_step = NextStepRunAgain() + state._pending_session_write = { + "session_id": "conv-1", + "items": [ + { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + "id": "__fake_id__", + }, + { + "type": "function_call_output", + "call_id": "call_PARKED", + "output": "wrote:x", + "id": "__fake_id__", + }, + ], + "before": None, + "persisted_count": 2, + "held": True, + } + + await resume_pending_session_write(state, session) # type: ignore[arg-type] + + assert state._pending_session_write is None + assert [item.get("call_id") for item in session.added] == ["call_PARKED", "call_PARKED"] + assert all("id" not in item for item in session.added) From 0ce31cdffb3fe965e8288fe93a7f15d88858f42a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 14:51:54 +0200 Subject: [PATCH 19/30] fix(sessions): dedupe the rebuilt final response against the batch and pair every approval family Two more findings from the same review round. With output guardrails the final sweep rebuilds the whole current response, held batch included, and the item deduplication cannot key the batch's unkeyed companions, so an assistant preamble landed twice: when the final items already carry every held request the batch is redundant and is dropped, in both runners. And the pairing guard now speaks every supported approval identity, hosted MCP requests and responses included, instead of recognizing only the function-call pair. --- .../run_internal/agent_runner_helpers.py | 12 +- .../run_internal/session_persistence.py | 119 +++++++++++++++--- ...test_deferred_interrupted_session_write.py | 67 +++++++++- 3 files changed, 176 insertions(+), 22 deletions(-) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index e0887e05d0..2c0a56c7cb 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -42,7 +42,11 @@ NextStepRunAgain, ProcessedResponse, ) -from .session_persistence import save_result_to_session, save_resumed_turn_items +from .session_persistence import ( + final_items_cover_held_batch, + save_result_to_session, + save_resumed_turn_items, +) from .tool_use_tracker import AgentToolUseTracker, serialize_tool_use_tracker from .turn_preparation import get_model @@ -599,6 +603,12 @@ async def save_final_turn_items_after_guardrails( return 0 if input_guardrails_triggered(input_guardrail_results): return 0 + if held_input and final_items_cover_held_batch(items, held_input, reasoning_item_id_policy): + # The guardrail rebuild re-derived the whole current response, held requests + # included; feeding the batch again would duplicate its unkeyed companions. + # ``save_resumed_turn_items`` repeats this check for the paths that route + # through it; this copy covers the zero-count direct save below. + held_input = None if run_state is not None and run_state._current_turn_persisted_item_count > 0: run_state._current_turn_persisted_item_count = await save_resumed_turn_items( session=session, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 0618035b4c..d70bcb986a 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -15,6 +15,7 @@ from typing import Any, cast from .. import _debug +from .._tool_identity import get_hosted_mcp_approval_request_identity from ..exceptions import UserError from ..items import ( HandoffOutputItem, @@ -816,6 +817,12 @@ async def save_resumed_turn_items( """ if session is None or (not items and not held_input): return persisted_count + if held_input and final_items_cover_held_batch(items, held_input, reasoning_item_id_policy): + # The guardrail rebuild re-derived the whole current response, held requests + # included; feeding the batch again would duplicate its unkeyed companions. + # Resolved-turn saves never carry request kinds, so this only fires on the + # final sweep. + held_input = None if held_input: held_input = _held_items_safe_to_settle( held_input, @@ -848,16 +855,62 @@ async def save_resumed_turn_items( return persisted_count + saved_count +# Every supported approval family pairs a request kind with the output kind that +# completes it; the identity is ``call_id`` except for hosted MCP approvals, whose +# request carries ``id`` and whose response points back with ``approval_request_id``. +_HELD_PAIR_OUTPUT_KIND = { + "function_call": "function_call_output", + "custom_tool_call": "custom_tool_call_output", + "computer_call": "computer_call_output", + "local_shell_call": "local_shell_call_output", + "mcp_approval_request": "mcp_approval_response", +} + + +def _held_pair_identity(item: TResponseInputItem) -> tuple[str, str] | None: + """Return the pairing key a held request kind must find an output for.""" + if not isinstance(item, dict): + return None + item_type = item.get("type") + if item_type not in _HELD_PAIR_OUTPUT_KIND: + return None + if item_type == "mcp_approval_request": + identity = get_hosted_mcp_approval_request_identity(item) + request_id = identity.request_id if identity is not None else None + return (item_type, request_id) if request_id else None + call_id = item.get("call_id") + return (item_type, call_id) if isinstance(call_id, str) and call_id else None + + +def _held_pair_output_identity(item: TResponseInputItem) -> tuple[str, str] | None: + """Return the key an output kind provides toward pairing its request.""" + if not isinstance(item, dict): + return None + item_type = item.get("type") + if item_type == "mcp_approval_response": + request_id = item.get("approval_request_id") + return ( + ("mcp_approval_response", request_id) + if isinstance(request_id, str) and request_id + else None + ) + if item_type not in set(_HELD_PAIR_OUTPUT_KIND.values()): + return None + call_id = item.get("call_id") + return (item_type, call_id) if isinstance(call_id, str) and call_id else None + + def _pending_approval_call_ids(run_state: RunState | None) -> set[str]: - """Return the call ids still awaiting approval on the state's current step.""" + """Return the ids still awaiting approval on the state's current step.""" if run_state is None or not isinstance(run_state._current_step, NextStepInterruption): return set() ids: set[str] = set() for approval in run_state._current_step.interruptions: raw = getattr(approval, "raw_item", None) - call_id = raw.get("call_id") if isinstance(raw, dict) else getattr(raw, "call_id", None) - if isinstance(call_id, str) and call_id: - ids.add(call_id) + for field in ("call_id", "id"): + value = raw.get(field) if isinstance(raw, dict) else getattr(raw, field, None) + if isinstance(value, str) and value: + ids.add(value) return ids @@ -882,26 +935,52 @@ def _held_items_safe_to_settle( filter removed them, so they settle now and pair up at a later exit, exactly as a non-deferred park persists a call before its output exists. """ - output_ids = { - item.get("call_id") - for item in held_items - if isinstance(item, dict) and item.get("type") == "function_call_output" - } + output_ids: set[str] = set() + for item in held_items: + key = _held_pair_output_identity(item) + if key is not None: + output_ids.add(key[1]) if pending_call_ids: output_ids |= pending_call_ids for run_item in run_items: converted = run_item_to_input_item(run_item, reasoning_item_id_policy) - if isinstance(converted, dict) and converted.get("type") == "function_call_output": - output_ids.add(converted.get("call_id")) - return [ - item - for item in held_items - if not ( - isinstance(item, dict) - and item.get("type") == "function_call" - and item.get("call_id") not in output_ids - ) - ] + key = _held_pair_output_identity(converted) if converted is not None else None + if key is not None: + output_ids.add(key[1]) + kept: list[TResponseInputItem] = [] + for item in held_items: + key = _held_pair_identity(item) + if key is not None and key[1] not in output_ids: + continue + kept.append(item) + return kept + + +def final_items_cover_held_batch( + items: Sequence[RunItem], + held_input: Sequence[TResponseInputItem], + reasoning_item_id_policy: ReasoningItemIdPolicy | None, +) -> bool: + """Return whether the final batch already carries the held batch's requests. + + With output guardrails the final sweep rebuilds the whole current response, held + requests included, and the deduplication cannot key the batch's unkeyed companions + (an assistant preamble, an id-less reasoning item), so feeding the batch again + would duplicate them. When every held request already appears in the final items + the whole batch is redundant; without guardrails the sweep returns the resolved + items verbatim, no held request appears there, and the batch must ride in. + """ + final_ids: set[str] = set() + for run_item in items: + converted = run_item_to_input_item(run_item, reasoning_item_id_policy) + key = _held_pair_identity(converted) if converted is not None else None + if key is not None: + final_ids.add(key[1]) + for item in held_input: + key = _held_pair_identity(item) + if key is not None and key[1] not in final_ids: + return False + return True def defer_interrupted_session_write( diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 74ee7c1c64..9a4908a2ae 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -941,7 +941,9 @@ async def test_a_failed_final_settle_fails_closed_with_the_batch_recorded( # checkpoint is rejected on load on purpose: the run ended mid-settle, and failing # closed beats replaying an approved side effect as if nothing happened. session = _FailingResumeSession() - resume_agent = _make_terminal_tool_agent() + # A guardrail-less resume: the final sweep returns the resolved items verbatim, so + # the held batch itself rides the append that fails. + resume_agent = _make_terminal_tool_agent(with_guardrails=False) state = await _parked_and_approved( _make_terminal_tool_agent(), session, streamed=streamed, resume_agent=resume_agent ) @@ -1032,3 +1034,66 @@ async def test_entry_settle_restores_the_conversations_sanitization() -> None: assert state._pending_session_write is None assert [item.get("call_id") for item in session.added] == ["call_PARKED", "call_PARKED"] assert all("id" not in item for item in session.added) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_terminal_resume_with_a_preamble_lands_it_once(streamed: bool) -> None: + # With output guardrails the final sweep rebuilds the whole current response, held + # batch included; the deduplication cannot key the assistant preamble, so feeding + # the batch again used to land the preamble twice. + session = SimpleListSession() + agent = _make_terminal_tool_agent(with_preamble=True) + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert _orphaned_outputs(items) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + preambles = [item for item in items if _PREAMBLE_TEXT in json.dumps(item)] + assert len(preambles) == 1 + + +def test_the_pairing_guard_speaks_every_approval_identity() -> None: + # A hosted MCP approval request identifies itself with ``id`` and its response + # points back with ``approval_request_id``; custom calls pair by ``call_id``. A + # request kind the guard cannot key would settle alone and poison the Session the + # same way an unpaired function call does. + from agents.run_internal.session_persistence import _held_items_safe_to_settle + + unpaired_mcp: TResponseInputItem = { + "type": "mcp_approval_request", + "id": "mcpr_1", + "name": "do_it", + "server_label": "srv", + "arguments": "{}", + } + paired_mcp: TResponseInputItem = { + "type": "mcp_approval_request", + "id": "mcpr_2", + "name": "do_it", + "server_label": "srv", + "arguments": "{}", + } + mcp_response: TResponseInputItem = { + "type": "mcp_approval_response", + "approval_request_id": "mcpr_2", + "approve": True, + } + unpaired_custom: TResponseInputItem = { + "type": "custom_tool_call", + "call_id": "cust_1", + "name": "custom", + "input": "", + } + preamble: TResponseInputItem = {"role": "assistant", "content": "hi", "type": "message"} + + kept = _held_items_safe_to_settle( + [unpaired_mcp, paired_mcp, mcp_response, unpaired_custom, preamble], [], None + ) + assert kept == [paired_mcp, mcp_response, preamble] + + still_pending = _held_items_safe_to_settle( + [unpaired_mcp], [], None, pending_call_ids={"mcpr_1"} + ) + assert still_pending == [unpaired_mcp] From 1b916c7f55320a5c71fdd52cf873a70656f4b13e Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 15:31:44 +0200 Subject: [PATCH 20/30] fix(sessions): settle held batches through the canonical pairing and counting rules Four more findings from the same review round. The held pairing guard now delegates to the canonical drop_orphan_function_calls, so every tool-call family in _TOOL_CALL_TO_OUTPUT_TYPE pairs (shell and apply-patch included) and a reasoning item riding before a dropped call is pruned with it, as the Responses API requires. A Conversations-backed registration forces the reasoning-id policy to None like the normal save, so a server-identified reasoning item stays persistable. And settled held items count toward the turn's persisted count, so a later gate-enabled resume fails fast on the persisted-items refusal instead of re-appending the stored calls. --- .../run_internal/session_persistence.py | 98 +++++++------- ...test_deferred_interrupted_session_write.py | 120 ++++++++++++++++++ 2 files changed, 172 insertions(+), 46 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index d70bcb986a..44f0f7ec3b 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -44,6 +44,7 @@ from ..run_context import RunContextWrapper from ..run_state import RunState, _PendingSessionWrite from .items import ( + _TOOL_CALL_TO_OUTPUT_TYPE, NestedHistoryOwnedItem, NestedHistoryOwnedItemRef, ReasoningItemIdPolicy, @@ -852,49 +853,29 @@ async def save_resumed_turn_items( else None ), ) - return persisted_count + saved_count + # Settled held items are this turn's persisted items too. Leaving them uncounted + # would let a later gate-enabled resume pass the resumed-safety validation with a + # zero count and re-append the stored calls through the final sweep; counting them + # makes that resume fail fast on the existing persisted-items refusal instead. + return persisted_count + saved_count + len(held_input or []) -# Every supported approval family pairs a request kind with the output kind that -# completes it; the identity is ``call_id`` except for hosted MCP approvals, whose -# request carries ``id`` and whose response points back with ``approval_request_id``. -_HELD_PAIR_OUTPUT_KIND = { - "function_call": "function_call_output", - "custom_tool_call": "custom_tool_call_output", - "computer_call": "computer_call_output", - "local_shell_call": "local_shell_call_output", - "mcp_approval_request": "mcp_approval_response", -} +def _held_pair_identity(item: TResponseInputItem | None) -> tuple[str, str] | None: + """Return the pairing key a held request kind must find an output for. - -def _held_pair_identity(item: TResponseInputItem) -> tuple[str, str] | None: - """Return the pairing key a held request kind must find an output for.""" + The tool-call families come from the canonical ``_TOOL_CALL_TO_OUTPUT_TYPE`` map + in ``run_internal.items``, which owns the call-to-output pairing rule; hosted MCP + approvals pair outside that map, keyed by the canonical request identity with the + response pointing back via ``approval_request_id``. + """ if not isinstance(item, dict): return None item_type = item.get("type") - if item_type not in _HELD_PAIR_OUTPUT_KIND: - return None if item_type == "mcp_approval_request": identity = get_hosted_mcp_approval_request_identity(item) request_id = identity.request_id if identity is not None else None return (item_type, request_id) if request_id else None - call_id = item.get("call_id") - return (item_type, call_id) if isinstance(call_id, str) and call_id else None - - -def _held_pair_output_identity(item: TResponseInputItem) -> tuple[str, str] | None: - """Return the key an output kind provides toward pairing its request.""" - if not isinstance(item, dict): - return None - item_type = item.get("type") - if item_type == "mcp_approval_response": - request_id = item.get("approval_request_id") - return ( - ("mcp_approval_response", request_id) - if isinstance(request_id, str) and request_id - else None - ) - if item_type not in set(_HELD_PAIR_OUTPUT_KIND.values()): + if item_type not in _TOOL_CALL_TO_OUTPUT_TYPE: return None call_id = item.get("call_id") return (item_type, call_id) if isinstance(call_id, str) and call_id else None @@ -935,24 +916,44 @@ def _held_items_safe_to_settle( filter removed them, so they settle now and pair up at a later exit, exactly as a non-deferred park persists a call before its output exists. """ - output_ids: set[str] = set() + pending_call_ids = pending_call_ids or set() + working: list[TResponseInputItem] = list(held_items) + # A call whose approval is still open is exempt from the orphan prune; the prune + # only understands outputs, so the exemption rides in as a placeholder output that + # is discarded with the rest of the context below. for item in held_items: - key = _held_pair_output_identity(item) - if key is not None: - output_ids.add(key[1]) - if pending_call_ids: - output_ids |= pending_call_ids + key = _held_pair_identity(item) + if key is not None and key[1] in pending_call_ids and key[0] != "mcp_approval_request": + working.append( + cast( + TResponseInputItem, + {"type": _TOOL_CALL_TO_OUTPUT_TYPE[key[0]], "call_id": key[1]}, + ) + ) for run_item in run_items: converted = run_item_to_input_item(run_item, reasoning_item_id_policy) - key = _held_pair_output_identity(converted) if converted is not None else None - if key is not None: - output_ids.add(key[1]) + if converted is not None: + working.append(converted) + pruned = drop_orphan_function_calls(working) + surviving = {id(item) for item in pruned} + + # Hosted MCP approvals pair outside the canonical map: a request settles only with + # its response present or its approval still open. + mcp_response_ids = { + item.get("approval_request_id") + for item in working + if isinstance(item, dict) and item.get("type") == "mcp_approval_response" + } | pending_call_ids + kept: list[TResponseInputItem] = [] for item in held_items: - key = _held_pair_identity(item) - if key is not None and key[1] not in output_ids: - continue - kept.append(item) + if isinstance(item, dict) and item.get("type") == "mcp_approval_request": + key = _held_pair_identity(item) + if key is not None and key[1] not in mcp_response_ids: + continue + kept.append(item) + elif id(item) in surviving: + kept.append(item) return kept @@ -1014,6 +1015,11 @@ def defer_interrupted_session_write( if pending is not None and not pending.get("held"): raise UserError("Resolve the pending Session write before saving another batch") + # The normal persistence path forces the reasoning-id policy to ``None`` for a + # Conversations backend so a server-identified reasoning item stays persistable; + # the registration conversion must match or the sanitization later drops it. + if isinstance(session, OpenAIConversationsSession): + reasoning_item_id_policy = None converted_run_items: list[TResponseInputItem] = [] for run_item in run_items: as_input = run_item_to_input_item(run_item, reasoning_item_id_policy) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 9a4908a2ae..dea3f2277f 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1097,3 +1097,123 @@ def test_the_pairing_guard_speaks_every_approval_identity() -> None: [unpaired_mcp], [], None, pending_call_ids={"mcpr_1"} ) assert still_pending == [unpaired_mcp] + + +def test_the_pairing_guard_prunes_with_the_canonical_rule() -> None: + # The prune delegates to drop_orphan_function_calls, so every family that map + # owns pairs correctly (a shell call included) and a reasoning item riding + # immediately before a dropped call goes with it: the Responses API rejects + # reasoning without its required following item. + from agents.run_internal.session_persistence import _held_items_safe_to_settle + + reasoning: TResponseInputItem = {"type": "reasoning", "id": "rs_1", "summary": []} + unpaired_shell: TResponseInputItem = { + "type": "shell_call", + "call_id": "sh_1", + "id": "sh_item_1", + "status": "completed", + "action": {"type": "exec", "command": "ls"}, + } + paired_call: TResponseInputItem = { + "type": "function_call", + "call_id": "fn_1", + "name": "write_thing", + "arguments": "{}", + } + paired_output: TResponseInputItem = { + "type": "function_call_output", + "call_id": "fn_1", + "output": "ok", + } + + kept = _held_items_safe_to_settle( + [reasoning, unpaired_shell, paired_call, paired_output], [], None + ) + assert kept == [paired_call, paired_output] + + still_pending = _held_items_safe_to_settle( + [reasoning, unpaired_shell], [], None, pending_call_ids={"sh_1"} + ) + assert still_pending == [reasoning, unpaired_shell] + + +@pytest.mark.asyncio +async def test_settled_held_items_count_toward_the_turn_persisted_count() -> None: + # A held batch can settle with no accompanying run items (an approval-only turn + # converts to nothing persistable), so it lands through the original_input slot and + # save_result_to_session returns zero new items. The settled calls are still this + # turn's persisted items: leaving them uncounted would let a later gate-enabled + # resume pass the resumed-safety validation with a zero count and re-append them. + from agents.run_internal.run_steps import NextStepRunAgain + from agents.run_internal.session_persistence import save_resumed_turn_items + + session = SimpleListSession() + state = RunState( + context=None, + original_input="go", + starting_agent=_make_deferring_agent(), + max_turns=5, + ) + state._current_step = NextStepRunAgain() + held = [ + { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + }, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "wrote:x"}, + ] + + count = await save_resumed_turn_items( + session=session, + items=[], + held_input=held, # type: ignore[arg-type] + persisted_count=0, + response_id=None, + run_state=state, + ) + + assert count == 2 + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +async def test_registration_forces_the_conversations_reasoning_policy() -> None: + # A Conversations backend keeps a server-identified reasoning item persistable by + # forcing the reasoning-id policy to None, exactly as the normal save path does; a + # deferred registration under "omit" must match or the sanitization drops it. + from openai.types.responses import ResponseReasoningItem + from openai.types.responses.response_reasoning_item import Summary + + from agents.items import ReasoningItem + from agents.run_internal.session_persistence import defer_interrupted_session_write + + agent = _make_deferring_agent() + state = RunState( + context=None, + original_input="go", + starting_agent=agent, + max_turns=5, + ) + state._reasoning_item_id_policy = "omit" + reasoning = ReasoningItem( + agent=agent, + raw_item=ResponseReasoningItem( + id="rs_server_1", + summary=[Summary(text="because", type="summary_text")], + type="reasoning", + ), + ) + + defer_interrupted_session_write( + state, + _RecordingConversationsSession(), # type: ignore[arg-type] + run_items=[reasoning], + reasoning_item_id_policy="omit", + ) + + pending = state._pending_session_write + assert pending is not None + reasoning_items = [item for item in pending["items"] if item.get("type") == "reasoning"] + assert reasoning_items and reasoning_items[0].get("id") == "rs_server_1" From ca6dd8233ee2064b788dba8aabee95f1cea10706 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 5 Sep 2026 18:34:43 +0200 Subject: [PATCH 21/30] fix(sessions): keep crash recovery armed when the rebuilt final items cover the held batch The guardrail rebuild deduplicates the held batch out of the append to avoid doubling its unkeyed companions, but the append still lands the approved call and output, so the recovery registration must stay armed. Arming now keys off whether a held batch was claimed at all, captured before the dedup empties the payload, in both the resumed-turn helper and the zero-count final save; a crash inside the append leaves the batch recorded to reconcile on retry instead of silently losing it. --- .../run_internal/agent_runner_helpers.py | 30 +++++-- .../run_internal/session_persistence.py | 7 +- ...test_deferred_interrupted_session_write.py | 87 +++++++++++++++++++ 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 2c0a56c7cb..ee7a71c1cf 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -43,6 +43,8 @@ ProcessedResponse, ) from .session_persistence import ( + _held_items_safe_to_settle, + _pending_approval_call_ids, final_items_cover_held_batch, save_result_to_session, save_resumed_turn_items, @@ -603,13 +605,13 @@ async def save_final_turn_items_after_guardrails( return 0 if input_guardrails_triggered(input_guardrail_results): return 0 - if held_input and final_items_cover_held_batch(items, held_input, reasoning_item_id_policy): - # The guardrail rebuild re-derived the whole current response, held requests - # included; feeding the batch again would duplicate its unkeyed companions. - # ``save_resumed_turn_items`` repeats this check for the paths that route - # through it; this copy covers the zero-count direct save below. - held_input = None + # Whether a held batch is being claimed at all, captured before any dedup empties + # it: the recovery registration below must stay armed even when the guardrail + # rebuild already carries the batch. + settling_held = bool(held_input) if run_state is not None and run_state._current_turn_persisted_item_count > 0: + # save_resumed_turn_items owns the dedup, pairing, and recovery arming; the raw + # held batch rides in so it can arm from its own pre-dedup view. run_state._current_turn_persisted_item_count = await save_resumed_turn_items( session=session, items=items, @@ -622,6 +624,17 @@ async def save_final_turn_items_after_guardrails( held_input=held_input, ) return run_state._current_turn_persisted_item_count + if held_input and final_items_cover_held_batch(items, held_input, reasoning_item_id_policy): + # The guardrail rebuild re-derived the whole current response, held requests + # included; feeding the batch again would duplicate its unkeyed companions. + held_input = None + if held_input: + held_input = _held_items_safe_to_settle( + held_input, + items, + reasoning_item_id_policy, + pending_call_ids=_pending_approval_call_ids(run_state), + ) return await save_result_to_session( session, list(held_input) if held_input else [], @@ -632,8 +645,9 @@ async def save_final_turn_items_after_guardrails( store=store, wrapper=wrapper, # A settling held batch always registers, so a crash inside this append fails - # closed with the batch recorded instead of silently losing it. - resumed_write_state=run_state if held_input else None, + # closed with the batch recorded instead of silently losing it, even when the + # payload was deduplicated from the append. + resumed_write_state=run_state if settling_held else None, ) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 44f0f7ec3b..6d4827bec1 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -818,6 +818,11 @@ async def save_resumed_turn_items( """ if session is None or (not items and not held_input): return persisted_count + # Whether this settle is claiming a held batch at all, captured before the dedup + # below can empty it: the recovery registration must stay armed even when the + # guardrail rebuild already carries the batch, because the append still lands the + # approved call and output and a crash inside it must reconcile on retry. + settling_held = bool(held_input) if held_input and final_items_cover_held_batch(items, held_input, reasoning_item_id_policy): # The guardrail rebuild re-derived the whole current response, held requests # included; feeding the batch again would duplicate its unkeyed companions. @@ -848,7 +853,7 @@ async def save_resumed_turn_items( # A settling held batch always registers, so a crash inside the append # fails closed with the batch recorded instead of silently losing the # only copy of an approved tool's call and output. - or bool(held_input) + or settling_held ) else None ), diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index dea3f2277f..28cec39f4f 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -960,6 +960,32 @@ async def test_a_failed_final_settle_fails_closed_with_the_batch_recorded( await RunState.from_json(resume_agent, state.to_json()) +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_failed_guarded_final_settle_fails_closed(streamed: bool) -> None: + # With output guardrails the final sweep rebuilds the response and the held batch + # is deduplicated out of the append, but the append still lands the approved call + # and output, so the recovery registration must stay armed: a crash inside it must + # leave the batch recorded, not silently lost. Guards the interaction between the + # dedup and the crash-safe registration. + session = _FailingResumeSession() + resume_agent = _make_terminal_tool_agent(with_preamble=True) + state = await _parked_and_approved( + _make_terminal_tool_agent(with_preamble=True), + session, + streamed=streamed, + resume_agent=resume_agent, + ) + + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await _run(resume_agent, state, session, streamed=streamed) + + pending = state._pending_session_write + assert pending is not None + assert "call_PARKED" in {item.get("call_id") for item in pending["items"]} + + class _RecordingConversationsSession: """Stand-in with the Conversations class identity, at the boundary the settle checks. @@ -1217,3 +1243,64 @@ async def test_registration_forces_the_conversations_reasoning_policy() -> None: assert pending is not None reasoning_items = [item for item in pending["items"] if item.get("type") == "reasoning"] assert reasoning_items and reasoning_items[0].get("id") == "rs_server_1" + + +@pytest.mark.asyncio +async def test_zero_count_final_save_arms_recovery_even_when_deduplicated() -> None: + # The zero-count branch of the final save: with guardrails the rebuilt items carry + # the held batch, so it deduplicates out of the append, yet the append still lands + # the approved call and output. The recovery registration must stay armed off the + # claimed-batch flag, not the emptied payload, or a failing append loses the batch + # with no pending record to reconcile. + from openai.types.responses import ResponseFunctionToolCall + + from agents.items import ToolCallItem, ToolCallOutputItem + from agents.run_internal.agent_runner_helpers import save_final_turn_items_after_guardrails + + session = _FailingResumeSession() + agent = _make_deferring_agent() + state = RunState(context=None, original_input="go", starting_agent=agent, max_turns=5) + state._current_turn_persisted_item_count = 0 + + call = ResponseFunctionToolCall( + call_id="call_PARKED", name="write_thing", arguments="{}", type="function_call" + ) + held = [ + { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + }, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "wrote:x"}, + ] + # The rebuilt final items already contain the held batch (guardrail rebuild), so the + # held payload deduplicates out of the append. + final_items = [ + ToolCallItem(agent=agent, raw_item=call), + ToolCallOutputItem( + agent=agent, + raw_item={ + "type": "function_call_output", + "call_id": "call_PARKED", + "output": "wrote:x", + }, + output="wrote:x", + ), + ] + + session.failure = "before" + with pytest.raises(RuntimeError, match="session append failed"): + await save_final_turn_items_after_guardrails( + session=session, + run_state=state, + session_persistence_enabled=True, + input_guardrail_results=[], + items=final_items, + response_id=None, + held_input=held, # type: ignore[arg-type] + ) + + # The append was registered before it ran, so the batch is recorded to reconcile. + assert state._pending_session_write is not None + assert "call_PARKED" in {i.get("call_id") for i in state._pending_session_write["items"]} From ab167f5c42dab443264440acc1322d8d5a4cf959 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 16:33:27 +0200 Subject: [PATCH 22/30] fix(sessions): give the held pending write its own schema version, settle it through the canonical path, and close the terminal and commit-boundary gaps Recovered work addressing the four blocking review points: - The held variant adds keys the released 1.17 reader rejects by exact key set, so it now has its own schema version. 1.17 keeps its four-key form and its original summary; held and response_id are gated to 1.18, with corpus fixtures, sources, README and the version-boundary test updated. - The entry settle no longer appends behind the canonical persistence path: it goes through save_result_to_session like every other settle, inheriting the Conversations sanitization, the ordered dedup, the pending-write registration and the compaction bookkeeping for the response the batch belongs to, which the park now records. - A max-turn handler ends the run, so both runners discard a held batch there. - The resumed turn's output committer folds a committed tool output into the held batch, so a post-output callback that raises cannot leave a retry that skips the completed invocation and drops the executed call and its result. --- src/agents/run.py | 3 + src/agents/run_internal/run_loop.py | 17 +- .../run_internal/session_persistence.py | 40 +-- src/agents/run_internal/turn_resolution.py | 12 + src/agents/run_state.py | 30 ++- tests/fixtures/run_state/README.md | 4 +- .../v1_18_held_pending_session_write.json | 82 ++++++ tests/fixtures/run_state/minimal/v1_18.json | 60 +++++ tests/fixtures/run_state/sources.json | 16 ++ tests/sandbox/test_docker.py | 2 +- ...test_deferred_interrupted_session_write.py | 255 ++++++++++++++++-- tests/test_run_impl_resume_paths.py | 17 +- tests/test_run_state.py | 1 + 13 files changed, 489 insertions(+), 50 deletions(-) create mode 100644 tests/fixtures/run_state/features/v1_18_held_pending_session_write.json create mode 100644 tests/fixtures/run_state/minimal/v1_18.json diff --git a/src/agents/run.py b/src/agents/run.py index 1888a40f8a..c4d8a74ff2 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1197,6 +1197,7 @@ def _mark_response_hooks_started() -> None: reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), + response_id=turn_result.model_response.response_id, ) elif turn_session_items: run_state._current_turn_persisted_item_count = ( @@ -1611,6 +1612,7 @@ async def _save_max_turns_handler_output( output_guardrail_results=output_guardrail_results, save_items_after_guardrails=_save_max_turns_handler_output, include_in_history=include_in_history, + run_state=run_state, ) if include_in_history and not handler_output_recorded: # Only reachable once the handler output cleared its guardrails and @@ -2161,6 +2163,7 @@ async def _save_max_turns_handler_output( reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), + response_id=turn_result.model_response.response_id, ) else: await save_result_to_session( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index a51d10c79f..1d6d1dde70 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -776,8 +776,16 @@ async def finalize_max_turns_handler_output( output_guardrail_results: list[OutputGuardrailResult], save_items_after_guardrails: Callable[[list[RunItem]], Awaitable[None]], include_in_history: bool, + run_state: RunState | None = None, ) -> tuple[Any, RunItem]: - """Validate and finalize one synthesized max-turn handler output.""" + """Validate and finalize one synthesized max-turn handler output. + + A max-turn handler ends the run, so a held Session write still standing here has + no later gate-legal exit to settle it: it is discarded, exactly as a detached + completion discards it, so the finished run's checkpoint stays loadable and both + runners report the same terminal state. + """ + take_held_session_write(run_state) validated_output = validate_handler_final_output(agent, output) output_text = format_final_output_text(agent, validated_output) synthesized_item = create_message_output_item(agent, output_text) @@ -1439,6 +1447,7 @@ async def _save_max_turns_items( reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), + response_id=turn_result.model_response.response_id, ) reinterruption_items = [] elif turn_session_items: @@ -1763,6 +1772,11 @@ def _record_max_turns_handler_output( break streamed_result._max_turns_handled = True streamed_result.current_turn = max_turns + # A max-turn handler ends the run, so a held Session write still + # standing has no later gate-legal exit to settle it. Discarding it + # keeps the finished run's checkpoint loadable and matches the + # non-streaming runner, which reports the same terminal state. + take_held_session_write(run_state) if run_state is not None and not is_resumed_state: run_state._current_turn = max_turns run_state._current_step = None @@ -2055,6 +2069,7 @@ def _record_max_turns_handler_output( session, run_items=turn_session_items, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), + response_id=turn_result.model_response.response_id, ) await _finalize_streamed_interruption( streamed_result=streamed_result, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 6d4827bec1..efad0fdbcf 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -995,6 +995,7 @@ def defer_interrupted_session_write( *, run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + response_id: str | None = None, ) -> None: """Register the interruption's withheld batch as a held pending Session write. @@ -1055,6 +1056,10 @@ def defer_interrupted_session_write( run_state._current_turn_persisted_item_count + len(converted_run_items) ), "held": True, + # The response the withheld batch belongs to, so the settle can run the same + # compaction bookkeeping the ordinary persistence path runs for it. An extend + # keeps the original response: the batch is that response's write. + "response_id": (pending.get("response_id") if pending is not None else None) or response_id, } run_state._pending_session_write = record @@ -1138,23 +1143,26 @@ async def resume_pending_session_write( return # The entry settle offers the batch with no accompanying resolved items, so the # pairing contract applies against the batch alone: a call whose output a - # detached handoff filter dropped must not land dangling here either. A batch - # extended while detached also missed the Conversations-specific sanitization, - # so the attached backend's invariant is restored before the direct append. - if isinstance(session, OpenAIConversationsSession): - pending["items"] = [ - _sanitize_openai_conversation_item(item) for item in pending["items"] - ] - pending["items"] = [ - item - for item in pending["items"] - if not _is_unpersistable_for_openai_conversation(item) - ] - pending["items"] = _held_items_safe_to_settle(pending["items"], [], None) - if not pending["items"]: - run_state._pending_session_write = None + # detached handoff filter dropped must not land dangling here either. + settling = _held_items_safe_to_settle(pending["items"], [], None) + response_id = pending.get("response_id") + run_state._pending_session_write = None + if not settling: return - pending.pop("held", None) + # Settle through the canonical persistence path rather than appending behind + # its back: it owns the Conversations sanitization, the ordered dedup, the + # pending-write registration that makes a failed append recoverable, and the + # compaction bookkeeping for the response this batch belongs to. + await save_result_to_session( + session, + settling, + [], + run_state, + response_id=response_id, + wrapper=wrapper, + resumed_write_state=run_state, + ) + return if run_state._session_write_in_progress: raise UserError("The pending Session write is already in progress for this RunState") if session is None or session.session_id != pending["session_id"]: diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index ae9fec7619..7bdc39e237 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -152,6 +152,7 @@ ToolRunMCPApprovalRequest, ToolRunShellCall, ) +from .session_persistence import extend_held_session_write from .tool_caller import ensure_programmatic_tool_call_parent, ensure_tool_caller_allowed from .tool_execution import ( build_litellm_json_tool_call, @@ -2457,6 +2458,17 @@ def _commit_tool_output(item: RunItem) -> None: ) if run_state is not None: run_state._generated_items = [*original_pre_step_items, *committed_tool_outputs] + # The approved tool's side effect is done and its output is committed, so + # the withheld batch takes it at this boundary rather than at the turn + # exit. A post-output callback that raises (``custom_data_extractor``, + # ``on_tool_end``) leaves a retry that skips the completed invocation and + # produces no new session items, and the batch would otherwise settle, or + # be discarded as an emptied turn, without the output the tool produced. + extend_held_session_write( + run_state, + run_items=[item], + reasoning_item_id_policy=run_state._reasoning_item_id_policy, + ) _register_tool_call_items(context_wrapper, [item]) ( diff --git a/src/agents/run_state.py b/src/agents/run_state.py index d07b6c587a..63de0b9c85 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -176,6 +176,10 @@ class _PendingSessionWrite(TypedDict): from that point it is an ordinary pending write and the digest reconciliation recovers a half-acknowledged append. Absent or ``False`` keeps the released meaning: an append already approved for eager settlement on resume entry. + + ``response_id`` records the model response the withheld batch belongs to, so the + settle can run the same compaction bookkeeping the ordinary persistence path does + for that response instead of appending behind its back. """ session_id: str @@ -183,6 +187,7 @@ class _PendingSessionWrite(TypedDict): before: list[str] | None persisted_count: int held: NotRequired[bool] + response_id: NotRequired[str | None] def _default_run_state_validation_error( @@ -199,10 +204,11 @@ def _default_run_state_validation_error( # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.17" +CURRENT_SCHEMA_VERSION = "1.18" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" _CURRENT_RESPONSE_OWNERSHIP_MIN_SCHEMA_VERSION = "1.17" +_HELD_PENDING_SESSION_WRITE_MIN_SCHEMA_VERSION = "1.18" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. SCHEMA_VERSION_SUMMARIES: dict[str, str] = { "1.0": "Initial RunState snapshot format for HITL pause/resume flows.", @@ -236,8 +242,11 @@ def _default_run_state_validation_error( ), "1.17": ( "Persists Docker container labels and current-response generated-item ownership across " - "resume flows, including pending resumed Session writes, their held-at-interruption " - "variant, and terminal-unrecoverable runs." + "resume flows, including pending resumed Session writes and terminal-unrecoverable runs." + ), + "1.18": ( + "Persists the interrupted turn's withheld Session write, including the response it " + "belongs to, so an approval resume can settle it under the output-guardrail gate." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -4382,13 +4391,26 @@ async def _build_run_state_from_json( if pending_write is not None: from .run_internal.run_steps import NextStepInterruption, NextStepRunAgain + # The held variant carries two keys the released 1.17 reader rejects, so it is + # gated to its own schema version; a 1.17 payload keeps exactly the four keys + # that version defined and settles eagerly as it always did. + held_keys_allowed = (schema_major, schema_minor) >= tuple( + int(part) + for part in _HELD_PENDING_SESSION_WRITE_MIN_SCHEMA_VERSION.split(".", maxsplit=1) + ) + base_keys = {"session_id", "items", "before", "persisted_count"} + held_keys = {"held", "response_id"} if held_keys_allowed else set() if ( (schema_major, schema_minor) < (1, 17) or not isinstance(state._current_step, NextStepRunAgain | NextStepInterruption) or not isinstance(pending_write, dict) - or set(pending_write) - {"held"} != {"session_id", "items", "before", "persisted_count"} + or set(pending_write) - held_keys != base_keys or ("held" in pending_write and type(pending_write["held"]) is not bool) or (pending_write.get("held") is True and pending_write.get("before") is not None) + or ( + "response_id" in pending_write + and not isinstance(pending_write["response_id"], str | type(None)) + ) or not isinstance(pending_write.get("session_id"), str) or not isinstance(pending_write.get("items"), list) or not pending_write["items"] diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md index 82836b310d..95464b1f47 100644 --- a/tests/fixtures/run_state/README.md +++ b/tests/fixtures/run_state/README.md @@ -1,6 +1,6 @@ # RunState compatibility corpus -The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.17. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. +The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.18. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture. Regenerate the feature corpus from the recorded historical source trees with: @@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/ The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout. -Versions 1.7, 1.8, 1.16, and 1.17 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. +Versions 1.7, 1.8, 1.16, 1.17, and 1.18 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. The 1.18 held-pending-write fixtures are written directly against the 1.18 reader that introduced the withheld-batch shape, which the 1.17 reader intentionally rejects. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison. diff --git a/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json b/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json new file mode 100644 index 0000000000..a432aaaae8 --- /dev/null +++ b/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json @@ -0,0 +1,82 @@ +{ + "$schemaVersion": "1.18", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": { + "type": "next_step_run_again" + }, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "pending_session_write": { + "before": null, + "held": true, + "items": [ + { + "arguments": "{}", + "call_id": "call_held_1", + "name": "write_thing", + "type": "function_call" + }, + { + "call_id": "call_held_1", + "output": "wrote", + "type": "function_call_output" + } + ], + "persisted_count": 2, + "response_id": "resp_held_1", + "session_id": "session-118" + }, + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/minimal/v1_18.json b/tests/fixtures/run_state/minimal/v1_18.json new file mode 100644 index 0000000000..62255e7502 --- /dev/null +++ b/tests/fixtures/run_state/minimal/v1_18.json @@ -0,0 +1,60 @@ +{ + "$schemaVersion": "1.18", + "auto_previous_response_id": false, + "context": { + "approvals": {}, + "context": {}, + "context_meta": { + "omitted": false, + "original_type": "mapping", + "requires_deserializer": false, + "serialized_via": "mapping" + }, + "tool_invocations": {}, + "usage": { + "input_tokens": 0, + "input_tokens_details": [ + { + "cache_write_tokens": 0, + "cached_tokens": 0 + } + ], + "output_tokens": 0, + "output_tokens_details": [ + { + "reasoning_tokens": 0 + } + ], + "request_usage_entries": [], + "requests": 0, + "total_tokens": 0 + } + }, + "conversation_id": null, + "current_agent": { + "name": "compat-agent" + }, + "current_step": null, + "current_turn": 0, + "current_turn_persisted_item_count": 0, + "generated_items": [], + "generated_prompt_cache_key": null, + "generated_session_item_indexes": [], + "input_guardrail_results": [], + "last_model_response": null, + "last_processed_response": null, + "max_turns": 10, + "model_responses": [], + "nested_history_owned_session_item_refs": [], + "no_active_agent_run": true, + "original_input": "historical input", + "output_guardrail_results": [], + "pending_input": [], + "previous_response_id": null, + "reasoning_item_id_policy": null, + "session_items": [], + "tool_input_guardrail_results": [], + "tool_output_guardrail_results": [], + "tool_use_tracker": {}, + "trace": null +} diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json index fc7be3fb55..fc0a18a06e 100644 --- a/tests/fixtures/run_state/sources.json +++ b/tests/fixtures/run_state/sources.json @@ -127,6 +127,15 @@ "note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving the Docker session payload.", "provenance": "canonical_compatibility", "version": "1.17" + }, + { + "commit": "821afdc3f709f409a307c93f42a603704be63033", + "emitted_version": "1.18", + "feature": "held_pending_session_write", + "fixture": "features/v1_18_held_pending_session_write.json", + "note": "The held pending Session write was first emitted with the 1.18 writer in this branch. The fixture carries the withheld batch under the version that introduced it, which the 1.17 reader intentionally rejects.", + "provenance": "canonical_compatibility", + "version": "1.18" } ], "resume": { @@ -195,6 +204,13 @@ "note": "The labels implementation was first emitted with the unreleased 1.16 writer. The fixture changes only the schema label to exercise the 1.17 compatibility reader while preserving older payload compatibility.", "provenance": "canonical_compatibility" }, + "1.18": { + "commit": "821afdc3f709f409a307c93f42a603704be63033", + "emitted_version": "1.18", + "fixture": "minimal/v1_18.json", + "note": "The held pending Session write was first emitted with the 1.18 writer in this branch. The minimal fixture changes only the schema label to exercise the 1.18 compatibility reader while preserving older payload compatibility.", + "provenance": "canonical_compatibility" + }, "1.2": { "commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c", "fixture": "minimal/v1_2.json" diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index e4c7cc812f..1f999e26b2 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -1926,7 +1926,7 @@ async def test_docker_labels_roundtrip_through_run_state() -> None: serialized = run_state.to_json() restored = await RunState.from_json(agent, serialized) - assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION == "1.17" + assert serialized["$schemaVersion"] == CURRENT_SCHEMA_VERSION == "1.18" assert restored._sandbox is not None restored_session_state = restored._sandbox["session_state"] assert isinstance(restored_session_state, dict) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 28cec39f4f..531e1d62db 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, Literal +from typing import Any, Literal, cast import pytest @@ -20,6 +20,9 @@ from agents.agent import Agent as AgentType from agents.exceptions import OutputGuardrailTripwireTriggered from agents.items import TResponseInputItem +from agents.lifecycle import RunHooks +from agents.memory.openai_conversations_session import OpenAIConversationsSession +from agents.run import RunConfig from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call from tests.utils.simple_session import SimpleListSession @@ -986,23 +989,17 @@ async def test_a_failed_guarded_final_settle_fails_closed(streamed: bool) -> Non assert "call_PARKED" in {item.get("call_id") for item in pending["items"]} -class _RecordingConversationsSession: - """Stand-in with the Conversations class identity, at the boundary the settle checks. +class _RecordingConversationsSession(OpenAIConversationsSession): + """Stand-in carrying the Conversations class identity the settle checks. - The real ``OpenAIConversationsSession`` talks to the Conversations API; the settle - only consults its class via ``isinstance`` to decide whether the batch needs the - Conversations sanitization, so the fake records what would be sent instead. + The real backend talks to the Conversations API; the settle only asks whether the + session is one of these to decide that the batch needs the Conversations + sanitization, so this records what would be sent instead of sending it. """ - def __new__(cls) -> _RecordingConversationsSession: - from agents.memory.openai_conversations_session import OpenAIConversationsSession - - instance = object.__new__( - type("_FakeConversations", (OpenAIConversationsSession,), dict(cls.__dict__)) - ) - instance.session_id = "conv-1" - instance.added: list[TResponseInputItem] = [] - return instance + def __init__(self) -> None: + self.session_id = "conv-1" + self.added: list[TResponseInputItem] = [] async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: return [] @@ -1132,14 +1129,17 @@ def test_the_pairing_guard_prunes_with_the_canonical_rule() -> None: # reasoning without its required following item. from agents.run_internal.session_persistence import _held_items_safe_to_settle - reasoning: TResponseInputItem = {"type": "reasoning", "id": "rs_1", "summary": []} - unpaired_shell: TResponseInputItem = { - "type": "shell_call", - "call_id": "sh_1", - "id": "sh_item_1", - "status": "completed", - "action": {"type": "exec", "command": "ls"}, - } + reasoning = cast("TResponseInputItem", {"type": "reasoning", "id": "rs_1", "summary": []}) + unpaired_shell = cast( + "TResponseInputItem", + { + "type": "shell_call", + "call_id": "sh_1", + "id": "sh_item_1", + "status": "completed", + "action": {"type": "exec", "command": "ls"}, + }, + ) paired_call: TResponseInputItem = { "type": "function_call", "call_id": "fn_1", @@ -1304,3 +1304,212 @@ async def test_zero_count_final_save_arms_recovery_even_when_deduplicated() -> N # The append was registered before it ran, so the batch is recorded to reconcile. assert state._pending_session_write is not None assert "call_PARKED" in {i.get("call_id") for i in state._pending_session_write["items"]} + + +class _CompactionRecordingSession(SimpleListSession): + """Record the compaction bookkeeping a compaction-aware backend expects.""" + + def __init__(self) -> None: + super().__init__() + self.compactions: list[dict[str, Any]] = [] + + async def _defer_compaction(self, response_id: str, store: bool | None = None) -> None: + self.compactions.append({"deferred": response_id, "store": store}) + + def _get_deferred_compaction_response_id(self) -> str | None: + return None + + async def run_compaction(self, args: Any = None) -> None: + self.compactions.append(dict(args or {})) + + +@pytest.mark.asyncio +async def test_the_entry_settle_runs_the_compaction_bookkeeping() -> None: + # The entry settle goes through the canonical persistence path, so a + # compaction-aware backend still gets the bookkeeping for the response the held + # batch belongs to. Appending behind that path would silently skip a supported + # compaction hook for the interrupted response. + from agents.run_internal.run_steps import NextStepRunAgain + from agents.run_internal.session_persistence import resume_pending_session_write + + session = _CompactionRecordingSession() + state = RunState( + context=None, + original_input="go", + starting_agent=_make_deferring_agent(), + max_turns=5, + ) + state._current_step = NextStepRunAgain() + state._pending_session_write = { + "session_id": "test", + "items": [ + { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + }, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "wrote:x"}, + ], + "before": None, + "persisted_count": 2, + "held": True, + "response_id": "resp_parked", + } + + await resume_pending_session_write(state, session) # type: ignore[arg-type] + + assert state._pending_session_write is None + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + assert any( + entry.get("response_id") == "resp_parked" or entry.get("deferred") == "resp_parked" + for entry in session.compactions + ), f"no compaction bookkeeping for the parked response: {session.compactions}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_park_records_the_response_the_batch_belongs_to(streamed: bool) -> None: + # The settle runs the compaction bookkeeping for the response the withheld batch + # came from, so the park has to record which response that was. + session = SimpleListSession() + agent = _make_deferring_agent() + + first = await _run(agent, "do the thing", session, streamed=streamed) + assert len(first.interruptions) == 1 + + pending = first.to_state().to_json()["pending_session_write"] + assert pending["held"] is True + assert pending["response_id"] == first.raw_responses[-1].response_id + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_max_turns_handler_completion_clears_the_held_record(streamed: bool) -> None: + # A max-turn handler ends the run, so a held batch still standing has no later + # gate-legal exit to settle it: both runners must report the same terminal state, + # with no pending write left to invalidate the finished run's checkpoint. + from agents.run_internal.run_loop import finalize_max_turns_handler_output + + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + assert state._pending_session_write is not None + + async def _no_save(items: list[Any]) -> None: + return None + + await finalize_max_turns_handler_output( + agent=agent, + hooks=RunHooks(), + run_config=RunConfig(tracing_disabled=True), + output="stopped at max turns", + context_wrapper=RunContextWrapper(context=None), + output_guardrail_results=[], + save_items_after_guardrails=_no_save, + include_in_history=False, + run_state=state, + ) + + assert state._pending_session_write is None + + +def _boom_custom_data_extractor(ctx: Any) -> dict[str, Any]: + raise RuntimeError("extractor boom") + + +@function_tool( + name_override="write_thing", + needs_approval=True, + custom_data_extractor=_boom_custom_data_extractor, +) +def write_thing_with_failing_extractor(query: str) -> str: + return f"wrote:{query}" + + +def _make_failing_extractor_agent() -> Agent: + """The approved tool succeeds, then its post-output callback raises.""" + return Agent( + name="deferred repro (failing extractor)", + instructions="x", + model=ScriptedModel( + [ + ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[look_up, write_thing_with_failing_extractor], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_post_output_callback_failure_keeps_the_executed_output(streamed: bool) -> None: + # The approved tool ran and its output was committed when the post-output callback + # raised. A retry skips the completed invocation and produces no new session items, + # so the batch has to carry that output from the commit boundary or the executed + # call and its result vanish from history. + session = SimpleListSession() + agent = _make_failing_extractor_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + with pytest.raises(Exception, match="extractor boom"): + await _run(agent, state, session, streamed=streamed) + + pending = state._pending_session_write + assert pending is not None + assert _parked_pair(pending["items"]) == _EXPECTED_PAIR + + +def _make_never_finishing_agent() -> Agent: + """Parks on turn two, then keeps calling tools so max turns is what ends the run.""" + steps = [ + ModelStep(output=[function_call("look_up", {"query": "a"}, call_id="call_LOOKUP")]), + ModelStep(output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")]), + ] + steps += [ + ModelStep(output=[function_call("look_up", {"query": f"q{i}"}, call_id=f"call_L{i}")]) + for i in range(8) + ] + return Agent( + name="deferred repro (never finishing)", + instructions="x", + model=ScriptedModel(steps), + tools=[look_up, write_thing], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +async def test_a_streamed_max_turns_completion_clears_the_held_record() -> None: + # The streaming runner reaches its max-turn handler through its own terminal path, + # not the shared helper, so it needs its own coverage: a detached resume that runs + # out of turns must not report terminal handler output while carrying a resumable + # pending write the non-streaming runner had already dropped. + session = SimpleListSession() + agent = _make_never_finishing_agent() + first = await _run(agent, "go", session, streamed=True) + assert len(first.interruptions) == 1 + state = await _serialized_round_trip(first, agent) + state.approve(state.get_interruptions()[0]) + assert state._pending_session_write is not None + + resumed = Runner.run_streamed( + agent, + state, + session=None, + max_turns=3, + error_handlers={"max_turns": lambda data: "stopped at max turns"}, + ) + async for _ in resumed.stream_events(): + pass + + assert resumed.final_output == "stopped at max turns" + assert "pending_session_write" not in resumed.to_state().to_json() + assert state._pending_session_write is None diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index d872a206e2..315ea1d749 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -499,7 +499,10 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( @pytest.mark.asyncio -@pytest.mark.parametrize("invalid", ["old-schema", "batch-shape", "held-shape", "held-with-before"]) +@pytest.mark.parametrize( + "invalid", + ["old-schema", "batch-shape", "held-shape", "held-with-before", "held-under-1-17"], +) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: agent, _, session, state, _ = await _approved_session_state(False) session.failure = "before" @@ -512,6 +515,12 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval payload["pending_session_write"]["items"] = "not an item batch" elif invalid == "held-shape": payload["pending_session_write"]["held"] = "yes" + elif invalid == "held-under-1-17": + # 1.17 defined the pending write as exactly four keys, so the held variant is + # only readable under the version that introduced it. + payload["$schemaVersion"] = "1.17" + payload["pending_session_write"]["held"] = True + payload["pending_session_write"]["before"] = None else: # A held batch was never offered to the Session, so recorded digests and the # held marker cannot coexist on one record. @@ -522,14 +531,16 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval @pytest.mark.asyncio async def test_pending_session_write_without_the_held_key_keeps_its_meaning() -> None: - # A checkpoint written before the held marker existed still settles eagerly on - # resume entry, exactly as released 1.17 behavior specified. + # The four-key form 1.17 defined still settles eagerly on resume entry under its + # own label, unchanged by the held variant that 1.18 introduced. agent, model, session, state, effects = await _approved_session_state(False) session.failure = "before" with pytest.raises(RuntimeError): await _run_session_resume(agent, state, session, False) payload = state.to_json() assert "held" not in payload["pending_session_write"] + payload["$schemaVersion"] = "1.17" + payload["pending_session_write"].pop("response_id", None) restored = await RunState.from_json(agent, payload) result = await _run_session_resume(agent, restored, session, False) diff --git a/tests/test_run_state.py b/tests/test_run_state.py index cd2daa51b6..4505ac8939 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -8611,6 +8611,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.14", "1.15", "1.16", + "1.17", CURRENT_SCHEMA_VERSION, } ) From b1775aa9321e250d4023a6d7c9d9b00f7988c4d9 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 16:51:07 +0200 Subject: [PATCH 23/30] fix(sessions): defer compaction when the settling batch carries the tool output The consolidated settle hands the held batch to the canonical path through the original_input slot, but the deferral decision only inspected new_items, so a batch containing the approved tool's output reported no local tool output and compacted the very response whose output had just landed. The decision now asks whether the append persists a local tool output at all, whichever slot carried it, and the batch records the store setting of the turn it was withheld in so the deferral resolves the same compaction mode the ordinary path would. --- src/agents/run.py | 2 ++ src/agents/run_internal/run_loop.py | 2 ++ src/agents/run_internal/session_persistence.py | 18 ++++++++++++++++++ src/agents/run_state.py | 15 +++++++++++---- .../test_deferred_interrupted_session_write.py | 11 +++++++---- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index c4d8a74ff2..d55135845d 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1198,6 +1198,7 @@ def _mark_response_hooks_started() -> None: run_state._reasoning_item_id_policy ), response_id=turn_result.model_response.response_id, + store=store_setting, ) elif turn_session_items: run_state._current_turn_persisted_item_count = ( @@ -2164,6 +2165,7 @@ async def _save_max_turns_handler_output( run_state._reasoning_item_id_policy ), response_id=turn_result.model_response.response_id, + store=store_setting, ) else: await save_result_to_session( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 1d6d1dde70..0bec3f12c0 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1448,6 +1448,7 @@ async def _save_max_turns_items( streamed_result._reasoning_item_id_policy ), response_id=turn_result.model_response.response_id, + store=store_setting, ) reinterruption_items = [] elif turn_session_items: @@ -2070,6 +2071,7 @@ def _record_max_turns_handler_output( run_items=turn_session_items, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), response_id=turn_result.model_response.response_id, + store=store_setting, ) await _finalize_streamed_interruption( streamed_result=streamed_result, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index efad0fdbcf..6ce701a1dc 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -94,6 +94,10 @@ _SESSION_LIMIT_UNSET = object() +# Serialized item types that represent a locally produced tool output, i.e. the output +# kinds of the canonical call-to-output map. +_LOCAL_TOOL_OUTPUT_TYPES = frozenset(_TOOL_CALL_TO_OUTPUT_TYPE.values()) + async def admit_pending_input( *, @@ -751,8 +755,16 @@ async def save_result_to_session( run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count if response_id and is_openai_responses_compaction_aware_session(session): + # A settling held batch carries its tool outputs as already-converted input + # items through ``original_input``, so looking only at ``new_items`` would + # report no local tool output and compact the very response whose outputs just + # landed. The question is whether this append persists any local tool output, + # whichever slot carried it. has_local_tool_outputs = any( isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items + ) or any( + isinstance(item, dict) and item.get("type") in _LOCAL_TOOL_OUTPUT_TYPES + for item in items_to_save ) if has_local_tool_outputs: defer_compaction = getattr(session, "_defer_compaction", None) @@ -996,6 +1008,7 @@ def defer_interrupted_session_write( run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, response_id: str | None = None, + store: bool | None = None, ) -> None: """Register the interruption's withheld batch as a held pending Session write. @@ -1060,6 +1073,9 @@ def defer_interrupted_session_write( # compaction bookkeeping the ordinary persistence path runs for it. An extend # keeps the original response: the batch is that response's write. "response_id": (pending.get("response_id") if pending is not None else None) or response_id, + "store": (pending.get("store") if pending is not None else None) + if (pending is not None and pending.get("store") is not None) + else store, } run_state._pending_session_write = record @@ -1146,6 +1162,7 @@ async def resume_pending_session_write( # detached handoff filter dropped must not land dangling here either. settling = _held_items_safe_to_settle(pending["items"], [], None) response_id = pending.get("response_id") + settle_store = pending.get("store") run_state._pending_session_write = None if not settling: return @@ -1159,6 +1176,7 @@ async def resume_pending_session_write( [], run_state, response_id=response_id, + store=settle_store, wrapper=wrapper, resumed_write_state=run_state, ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 63de0b9c85..4f43837e4c 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -177,9 +177,10 @@ class _PendingSessionWrite(TypedDict): recovers a half-acknowledged append. Absent or ``False`` keeps the released meaning: an append already approved for eager settlement on resume entry. - ``response_id`` records the model response the withheld batch belongs to, so the - settle can run the same compaction bookkeeping the ordinary persistence path does - for that response instead of appending behind its back. + ``response_id`` records the model response the withheld batch belongs to, and + ``store`` the store setting that response was produced under, so the settle runs + the same compaction bookkeeping the ordinary persistence path would have run for + it instead of appending behind its back. """ session_id: str @@ -188,6 +189,7 @@ class _PendingSessionWrite(TypedDict): persisted_count: int held: NotRequired[bool] response_id: NotRequired[str | None] + store: NotRequired[bool | None] def _default_run_state_validation_error( @@ -4399,7 +4401,7 @@ async def _build_run_state_from_json( for part in _HELD_PENDING_SESSION_WRITE_MIN_SCHEMA_VERSION.split(".", maxsplit=1) ) base_keys = {"session_id", "items", "before", "persisted_count"} - held_keys = {"held", "response_id"} if held_keys_allowed else set() + held_keys = {"held", "response_id", "store"} if held_keys_allowed else set() if ( (schema_major, schema_minor) < (1, 17) or not isinstance(state._current_step, NextStepRunAgain | NextStepInterruption) @@ -4411,6 +4413,11 @@ async def _build_run_state_from_json( "response_id" in pending_write and not isinstance(pending_write["response_id"], str | type(None)) ) + or ( + "store" in pending_write + and pending_write["store"] is not None + and type(pending_write["store"]) is not bool + ) or not isinstance(pending_write.get("session_id"), str) or not isinstance(pending_write.get("items"), list) or not pending_write["items"] diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 531e1d62db..12badb766c 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1361,10 +1361,13 @@ async def test_the_entry_settle_runs_the_compaction_bookkeeping() -> None: assert state._pending_session_write is None assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR - assert any( - entry.get("response_id") == "resp_parked" or entry.get("deferred") == "resp_parked" - for entry in session.compactions - ), f"no compaction bookkeeping for the parked response: {session.compactions}" + # The batch carries the approved tool's output, so this response's compaction must + # be DEFERRED, not run: compacting it here would discard the very output that just + # landed. Asserting the specific hook is the point; "some hook fired" would pass + # either way. + assert session.compactions == [{"deferred": "resp_parked", "store": None}], ( + f"expected a deferred compaction for the parked response, got {session.compactions}" + ) @pytest.mark.asyncio From 25ee168a04c380a9893184d9af084b8f845885fc Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 17:09:23 +0200 Subject: [PATCH 24/30] fix(sessions): make the 1.18 corpus entries reproducible and scope the held-only keys The corpus entries claimed a 1.18 writer for a commit that emits 1.17 and has no response_id, and the generator had no 1.18 scenario, so regenerating the corpus would have dropped them. Both fixtures are now what the recorded 1.17 writer emits with only the schema label changed, the generator carries the matching scenarios, and the README says the same thing. The reader also refuses response_id and store on an ordinary pending write, where they describe nothing, and the schema rationale no longer claims 1.17 shipped in a release: its readers are on main, which is reason enough not to rewrite what they already emit. --- .../run_internal/session_persistence.py | 5 +- src/agents/run_state.py | 15 ++++- tests/fixtures/run_state/README.md | 2 +- .../v1_18_held_pending_session_write.json | 1 - tests/fixtures/run_state/generate_corpus.py | 45 ++++++++++++++ tests/fixtures/run_state/sources.json | 8 +-- ...test_deferred_interrupted_session_write.py | 59 +++++++++++++++++++ 7 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 6ce701a1dc..102d4161da 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1169,7 +1169,10 @@ async def resume_pending_session_write( # Settle through the canonical persistence path rather than appending behind # its back: it owns the Conversations sanitization, the ordered dedup, the # pending-write registration that makes a failed append recoverable, and the - # compaction bookkeeping for the response this batch belongs to. + # compaction bookkeeping for the response this batch belongs to. The slot is + # released first, so the re-entry this causes (``save_result_to_session`` + # registers the batch and calls back into here) sees an ordinary pending write + # and takes the append-and-reconcile path below, never this branch again. await save_result_to_session( session, settling, diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 4f43837e4c..3a98d09ea4 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -4393,9 +4393,11 @@ async def _build_run_state_from_json( if pending_write is not None: from .run_internal.run_steps import NextStepInterruption, NextStepRunAgain - # The held variant carries two keys the released 1.17 reader rejects, so it is - # gated to its own schema version; a 1.17 payload keeps exactly the four keys - # that version defined and settles eagerly as it always did. + # 1.17 defines this object as exactly four keys and its readers are already on + # main, so writing the held variant under that label would emit checkpoints + # those readers reject. The held keys are therefore gated to the version that + # introduced them, and a 1.17 payload keeps the four keys it defined and + # settles eagerly as it always did. held_keys_allowed = (schema_major, schema_minor) >= tuple( int(part) for part in _HELD_PENDING_SESSION_WRITE_MIN_SCHEMA_VERSION.split(".", maxsplit=1) @@ -4418,6 +4420,13 @@ async def _build_run_state_from_json( and pending_write["store"] is not None and type(pending_write["store"]) is not bool ) + # Both keys describe the withheld response, so they are meaningless on an + # ordinary pending write and are refused there rather than restored as + # state nothing consumes. + or ( + not pending_write.get("held") + and ("response_id" in pending_write or "store" in pending_write) + ) or not isinstance(pending_write.get("session_id"), str) or not isinstance(pending_write.get("items"), list) or not pending_write["items"] diff --git a/tests/fixtures/run_state/README.md b/tests/fixtures/run_state/README.md index 95464b1f47..85655acf48 100644 --- a/tests/fixtures/run_state/README.md +++ b/tests/fixtures/run_state/README.md @@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/ The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout. -Versions 1.7, 1.8, 1.16, 1.17, and 1.18 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. The 1.18 held-pending-write fixtures are written directly against the 1.18 reader that introduced the withheld-batch shape, which the 1.17 reader intentionally rejects. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. +Versions 1.7, 1.8, 1.16, 1.17, and 1.18 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. The 1.18 held-pending-write transition uses the held-capable 1.17 writer and changes only the schema label; the 1.17 reader intentionally rejects that batch shape under its own label. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output. Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison. diff --git a/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json b/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json index a432aaaae8..c0487e666a 100644 --- a/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json +++ b/tests/fixtures/run_state/features/v1_18_held_pending_session_write.json @@ -69,7 +69,6 @@ } ], "persisted_count": 2, - "response_id": "resp_held_1", "session_id": "session-118" }, "previous_response_id": null, diff --git a/tests/fixtures/run_state/generate_corpus.py b/tests/fixtures/run_state/generate_corpus.py index ef7a33ee2e..687e1357ba 100644 --- a/tests/fixtures/run_state/generate_corpus.py +++ b/tests/fixtures/run_state/generate_corpus.py @@ -460,6 +460,38 @@ def approval(call_id): "reader while preserving the Docker session payload." ), ), + Scenario( + "1.18", + "821afdc3f709f409a307c93f42a603704be63033", + "held_pending_session_write", + """ +from agents.run_internal.run_steps import NextStepRunAgain + +state._current_step = NextStepRunAgain() +state._pending_session_write = { + "session_id": "session-118", + "items": [ + { + "type": "function_call", + "call_id": "call_held_1", + "name": "write_thing", + "arguments": "{}", + }, + {"type": "function_call_output", "call_id": "call_held_1", "output": "wrote"}, + ], + "before": None, + "persisted_count": 2, + "held": True, +} +""", + provenance="canonical_compatibility", + emitted_version="1.17", + note=( + "The held pending Session write was first emitted with the unreleased 1.17 writer. " + "The fixture changes only the schema label to exercise the 1.18 compatibility " + "reader while preserving the withheld batch payload." + ), + ), ) @@ -490,6 +522,19 @@ def approval(call_id): "reader while preserving older payload compatibility." ), ), + Scenario( + "1.18", + "821afdc3f709f409a307c93f42a603704be63033", + "minimal", + "", + provenance="canonical_compatibility", + emitted_version="1.17", + note=( + "The held pending Session write was first emitted with the unreleased 1.17 writer. " + "The fixture changes only the schema label to exercise the 1.18 compatibility " + "reader while preserving older payload compatibility." + ), + ), ) diff --git a/tests/fixtures/run_state/sources.json b/tests/fixtures/run_state/sources.json index fc0a18a06e..67d9e16a3f 100644 --- a/tests/fixtures/run_state/sources.json +++ b/tests/fixtures/run_state/sources.json @@ -130,10 +130,10 @@ }, { "commit": "821afdc3f709f409a307c93f42a603704be63033", - "emitted_version": "1.18", + "emitted_version": "1.17", "feature": "held_pending_session_write", "fixture": "features/v1_18_held_pending_session_write.json", - "note": "The held pending Session write was first emitted with the 1.18 writer in this branch. The fixture carries the withheld batch under the version that introduced it, which the 1.17 reader intentionally rejects.", + "note": "The held pending Session write was first emitted with the unreleased 1.17 writer. The fixture changes only the schema label to exercise the 1.18 compatibility reader while preserving the withheld batch payload.", "provenance": "canonical_compatibility", "version": "1.18" } @@ -206,9 +206,9 @@ }, "1.18": { "commit": "821afdc3f709f409a307c93f42a603704be63033", - "emitted_version": "1.18", + "emitted_version": "1.17", "fixture": "minimal/v1_18.json", - "note": "The held pending Session write was first emitted with the 1.18 writer in this branch. The minimal fixture changes only the schema label to exercise the 1.18 compatibility reader while preserving older payload compatibility.", + "note": "The held pending Session write was first emitted with the unreleased 1.17 writer. The fixture changes only the schema label to exercise the 1.18 compatibility reader while preserving older payload compatibility.", "provenance": "canonical_compatibility" }, "1.2": { diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 12badb766c..d07052fd37 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from dataclasses import replace from typing import Any, Literal, cast import pytest @@ -1516,3 +1517,61 @@ async def test_a_streamed_max_turns_completion_clears_the_held_record() -> None: assert resumed.final_output == "stopped at max turns" assert "pending_session_write" not in resumed.to_state().to_json() assert state._pending_session_write is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_park_records_the_store_the_response_was_produced_under( + streamed: bool, +) -> None: + # The settle defers compaction for the parked response, and the deferral resolves a + # compaction mode from the store setting. That setting belongs to the turn the + # batch was withheld in, not to the resume, so the park records it. + session = SimpleListSession() + agent = _make_deferring_agent() + agent.model_settings = replace(agent.model_settings, store=True) + + first = await _run(agent, "do the thing", session, streamed=streamed) + assert len(first.interruptions) == 1 + + pending = first.to_state().to_json()["pending_session_write"] + assert pending["held"] is True + assert pending["store"] is True + + +@pytest.mark.asyncio +async def test_the_entry_settle_defers_with_the_recorded_store() -> None: + # The recorded store reaches the deferral, so the hook resolves the same compaction + # mode the ordinary persistence path would have resolved for that response. + from agents.run_internal.run_steps import NextStepRunAgain + from agents.run_internal.session_persistence import resume_pending_session_write + + session = _CompactionRecordingSession() + state = RunState( + context=None, + original_input="go", + starting_agent=_make_deferring_agent(), + max_turns=5, + ) + state._current_step = NextStepRunAgain() + state._pending_session_write = { + "session_id": "test", + "items": [ + { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + }, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "wrote:x"}, + ], + "before": None, + "persisted_count": 2, + "held": True, + "response_id": "resp_parked", + "store": True, + } + + await resume_pending_session_write(state, session) # type: ignore[arg-type] + + assert session.compactions == [{"deferred": "resp_parked", "store": True}] From 9fed9caac722e833eaa3440d8a58097dbb547cc9 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 17:10:34 +0200 Subject: [PATCH 25/30] test: pin that the held-only keys are refused on an ordinary pending write --- tests/test_run_impl_resume_paths.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 315ea1d749..de7203f010 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -501,7 +501,14 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( @pytest.mark.asyncio @pytest.mark.parametrize( "invalid", - ["old-schema", "batch-shape", "held-shape", "held-with-before", "held-under-1-17"], + [ + "old-schema", + "batch-shape", + "held-shape", + "held-with-before", + "held-under-1-17", + "held-keys-without-held", + ], ) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: agent, _, session, state, _ = await _approved_session_state(False) @@ -515,6 +522,10 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval payload["pending_session_write"]["items"] = "not an item batch" elif invalid == "held-shape": payload["pending_session_write"]["held"] = "yes" + elif invalid == "held-keys-without-held": + # response_id and store describe the withheld response, so they are refused on + # an ordinary pending write where nothing consumes them. + payload["pending_session_write"]["response_id"] = "resp_1" elif invalid == "held-under-1-17": # 1.17 defined the pending write as exactly four keys, so the held variant is # only readable under the version that introduced it. From fd6311cf79c8e2658772ebeede7499645a651965 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 18:58:51 +0200 Subject: [PATCH 26/30] fix(sessions): settle the pairs an emptied resolved turn leaves behind A handoff input_filter can drop every resolved item, but by then the approved tool has run and its output was folded into the held batch. Emptiness of the turn was the wrong predicate: pairing is. The executed call and output now settle through the canonical path and only the unpaired requests drop, so the Session keeps the only record that the tool ran and a later run does not re-issue its side effect. Three defects the same review surfaced go with it: - The compaction deferral read the whole ``original_input`` slot, which carries the caller's own input on an ordinary save. Only a settling batch reads it now. - The settled count added the batch's raw length, overcounting whatever the dedup dropped; the append reports what it actually wrote, and that count slices a later save of the same turn. - The max-turns discard ran before ``validate_handler_final_output``, so a wrongly typed handler output lost the batch that the streamed runner keeps. Each is pinned by a test proven red against the previous behaviour. --- src/agents/run.py | 26 ++++- src/agents/run_internal/run_loop.py | 61 +++++++++- .../run_internal/session_persistence.py | 72 ++++++++++-- ...test_deferred_interrupted_session_write.py | 104 +++++++++++++++++- 4 files changed, 241 insertions(+), 22 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index d55135845d..74ad4c9d5a 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -150,6 +150,7 @@ save_result_to_session, save_resumed_turn_items, session_items_for_turn, + settle_held_batch_for_emptied_turn, take_held_session_write, update_run_state_after_resume, ) @@ -1219,11 +1220,26 @@ def _mark_response_hooks_started() -> None: ) ) else: - # An emptied resolved turn (a handoff input_filter can - # drop every item) discards the held batch: a call - # written without its output poisons the Session - # exactly as the orphaned output does. - take_held_session_write(run_state) + # An emptied resolved turn (a handoff input_filter + # can drop every item) still settles the pairs the + # batch already holds: the approved tool ran, and + # dropping its output with the filtered items would + # lose the Session's only record of that. + run_state._current_turn_persisted_item_count = ( + await settle_held_batch_for_emptied_turn( + run_state, + session, + persisted_count=( + run_state._current_turn_persisted_item_count + ), + response_id=(turn_result.model_response.response_id), + reasoning_item_id_policy=( + run_state._reasoning_item_id_policy + ), + store=store_setting, + wrapper=context_wrapper, + ) + ) # After the resumed turn, treat subsequent turns as fresh so # counters and input saving behave normally. diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 0bec3f12c0..509ce7af05 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -186,6 +186,7 @@ save_result_to_session, save_resumed_turn_items, session_items_for_turn, + settle_held_batch_for_emptied_turn, take_held_session_write, update_run_state_after_resume, ) @@ -785,8 +786,10 @@ async def finalize_max_turns_handler_output( completion discards it, so the finished run's checkpoint stays loadable and both runners report the same terminal state. """ - take_held_session_write(run_state) validated_output = validate_handler_final_output(agent, output) + # Only past the validation does the handler actually end the run; discarding above + # it would throw the batch away on a rejection the streamed runner survives. + take_held_session_write(run_state) output_text = format_final_output_text(agent, validated_output) synthesized_item = create_message_output_item(agent, output_text) @@ -1454,7 +1457,25 @@ async def _save_max_turns_items( elif turn_session_items: reinterruption_items = list(turn_session_items) else: - take_held_session_write(run_state) + # An emptied resolved turn still settles the pairs the + # batch already holds: the approved tool ran, and dropping + # its output with the filtered items would lose the + # Session's only record of that. + streamed_result._current_turn_persisted_item_count = ( + await settle_held_batch_for_emptied_turn( + run_state, + session, + persisted_count=( + streamed_result._current_turn_persisted_item_count + ), + response_id=turn_result.model_response.response_id, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + store=store_setting, + wrapper=streamed_result.context_wrapper, + ) + ) reinterruption_items = [] await _finalize_streamed_interruption( streamed_result=streamed_result, @@ -1495,7 +1516,23 @@ async def _save_max_turns_items( ), ) elif not turn_session_items: - take_held_session_write(run_state) + # An emptied resolved turn still settles the pairs the + # batch already holds; only the unpaired requests drop. + streamed_result._current_turn_persisted_item_count = ( + await settle_held_batch_for_emptied_turn( + run_state, + session, + persisted_count=( + streamed_result._current_turn_persisted_item_count + ), + response_id=turn_result.model_response.response_id, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + store=store_setting, + wrapper=streamed_result.context_wrapper, + ) + ) await _save_resumed_items( list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, @@ -1558,7 +1595,23 @@ async def _save_max_turns_items( ), ) elif not turn_session_items: - take_held_session_write(run_state) + # An emptied resolved turn still settles the pairs the + # batch already holds; only the unpaired requests drop. + streamed_result._current_turn_persisted_item_count = ( + await settle_held_batch_for_emptied_turn( + run_state, + session, + persisted_count=( + streamed_result._current_turn_persisted_item_count + ), + response_id=turn_result.model_response.response_id, + reasoning_item_id_policy=( + streamed_result._reasoning_item_id_policy + ), + store=store_setting, + wrapper=streamed_result.context_wrapper, + ) + ) await _save_resumed_items( list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 102d4161da..3882c8bab2 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -85,6 +85,7 @@ "defer_interrupted_session_write", "extend_held_session_write", "take_held_session_write", + "settle_held_batch_for_emptied_turn", "resume_pending_session_write", "update_run_state_after_resume", "rewind_session_items", @@ -636,13 +637,22 @@ async def save_result_to_session( store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, resumed_write_state: RunState | None = None, + settling_held_batch: bool = False, ) -> int: """ Persist a turn to the session store, keeping track of what was already saved so retries during streaming do not duplicate tool outputs or inputs. + ``settling_held_batch`` marks the calls that carry a withheld batch through + ``original_input``; only those look for local tool outputs in that slot, because on + an ordinary save the same slot holds the caller's own input. Those calls also count + the batch items this append actually wrote, which is not the batch's raw length: a + resolved turn re-delivers the outputs the batch already folded in and they dedup + away here. + Returns: - The number of new run items persisted for this call. + The number of new run items persisted for this call, plus the settled batch + items when ``settling_held_batch`` is set. """ already_persisted = run_state._current_turn_persisted_item_count if run_state is not None else 0 @@ -754,17 +764,25 @@ async def save_result_to_session( if run_state is not None: run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count + # The append wrote every deduplicated item; the ones that are not surviving run + # items are the settled batch's own, counted here because only this scope knows + # what the dedup kept. + settled_batch_items = len(items_to_save) - saved_run_items_count if settling_held_batch else 0 + if response_id and is_openai_responses_compaction_aware_session(session): # A settling held batch carries its tool outputs as already-converted input # items through ``original_input``, so looking only at ``new_items`` would # report no local tool output and compact the very response whose outputs just - # landed. The question is whether this append persists any local tool output, - # whichever slot carried it. + # landed. Only a settle reads that slot: on an ordinary save it holds the + # caller's input, whose earlier outputs say nothing about this response. has_local_tool_outputs = any( isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items - ) or any( - isinstance(item, dict) and item.get("type") in _LOCAL_TOOL_OUTPUT_TYPES - for item in items_to_save + ) or ( + settling_held_batch + and any( + isinstance(item, dict) and item.get("type") in _LOCAL_TOOL_OUTPUT_TYPES + for item in items_to_save + ) ) if has_local_tool_outputs: defer_compaction = getattr(session, "_defer_compaction", None) @@ -804,7 +822,7 @@ async def save_result_to_session( wrapper=compaction_wrapper, ) - return saved_run_items_count + return saved_run_items_count + settled_batch_items async def save_resumed_turn_items( @@ -857,6 +875,7 @@ async def save_resumed_turn_items( reasoning_item_id_policy=reasoning_item_id_policy, store=store, wrapper=wrapper, + settling_held_batch=settling_held, resumed_write_state=( run_state if run_state is not None @@ -873,8 +892,42 @@ async def save_resumed_turn_items( # Settled held items are this turn's persisted items too. Leaving them uncounted # would let a later gate-enabled resume pass the resumed-safety validation with a # zero count and re-append the stored calls through the final sweep; counting them - # makes that resume fail fast on the existing persisted-items refusal instead. - return persisted_count + saved_count + len(held_input or []) + # makes that resume fail fast on the existing persisted-items refusal instead. The + # append reports them itself, because the raw batch length overcounts whatever the + # dedup dropped and this count slices a later save of the same turn. + return persisted_count + saved_count + + +async def settle_held_batch_for_emptied_turn( + run_state: RunState | None, + session: Session | None, + *, + persisted_count: int, + response_id: str | None, + reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + store: bool | None = None, + wrapper: RunContextWrapper[Any] | None = None, +) -> int: + """Settle the paired part of a held batch whose resolved turn came back empty. + + A handoff ``input_filter`` can drop every resolved item, but an approved tool has + already run by then and its output was folded into the batch. Emptiness of the turn + is therefore the wrong predicate: pairing is. The executed call and output settle + together and the unpaired requests drop, exactly as every other settle decides it, + so the Session keeps the only record that the tool ran and the next run does not + re-issue its side effect. + """ + return await save_resumed_turn_items( + run_state=run_state, + session=session, + items=[], + held_input=take_held_session_write(run_state), + persisted_count=persisted_count, + response_id=response_id, + reasoning_item_id_policy=reasoning_item_id_policy, + store=store, + wrapper=wrapper, + ) def _held_pair_identity(item: TResponseInputItem | None) -> tuple[str, str] | None: @@ -1181,6 +1234,7 @@ async def resume_pending_session_write( response_id=response_id, store=settle_store, wrapper=wrapper, + settling_held_batch=True, resumed_write_state=run_state, ) return diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index d07052fd37..ad1f425ad8 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -522,12 +522,14 @@ async def test_a_detached_resume_does_not_make_the_next_one_rewrite_the_session( @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) -async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner( +async def test_an_emptied_resolved_turn_settles_the_paired_part_of_the_held_batch( streamed: bool, ) -> None: - # When a handoff input_filter empties the resolved turn, the held batch must not be - # written on its own: a call with no output poisons the Session exactly as the - # orphaned output does. + # A handoff input_filter empties the resolved turn, but the approved tool already + # ran and its output was folded into the held batch: pairing is the predicate, so + # the executed pair settles and only the unpaired call drops. Discarding the whole + # batch would lose the Session's only record that the tool ran, and the next run + # would re-issue its side effect. session = SimpleListSession() agent = _make_emptying_handoff_agent() state = await _parked_and_approved(agent, session, streamed=streamed) @@ -538,6 +540,8 @@ async def test_an_emptied_resolved_turn_corrupts_nothing_in_either_runner( outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} assert calls - outputs == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" assert outputs - calls == set(), f"orphaned outputs: {sorted(map(str, outputs - calls))}" + assert "call_PARKED" in calls, "the executed pair must survive the emptied turn" + assert "call_HANDOFF" not in calls, "the unpaired call must not be written" assert "pending_session_write" not in resumed.to_state().to_json() # The discard must reach the live state too: a stale held record would invalidate # any checkpoint later taken from this completed run. @@ -1307,6 +1311,42 @@ async def test_zero_count_final_save_arms_recovery_even_when_deduplicated() -> N assert "call_PARKED" in {i.get("call_id") for i in state._pending_session_write["items"]} +@pytest.mark.asyncio +async def test_the_settled_count_matches_what_the_append_actually_wrote() -> None: + # The resolved turn re-delivers the very output the batch already folded in, so it + # dedups away inside the append. Counting the batch by its raw length would report + # more persisted items than exist, and the count slices the next save of this turn + # positionally: an inflated count drops resolved items out of their own write. + from agents.items import ToolCallOutputItem + from agents.run_internal.session_persistence import save_resumed_turn_items + + agent = _make_deferring_agent() + call: TResponseInputItem = { + "type": "function_call", + "call_id": "call_PARKED", + "name": "write_thing", + "arguments": "{}", + } + output: TResponseInputItem = { + "type": "function_call_output", + "call_id": "call_PARKED", + "output": "wrote:x", + } + session = SimpleListSession() + + count = await save_resumed_turn_items( + run_state=None, + session=session, + items=[ToolCallOutputItem(agent=agent, raw_item=output, output="wrote:x")], + held_input=[call, output], + persisted_count=0, + response_id=None, + reasoning_item_id_policy=None, + ) + + assert count == len(await session.get_items()) + + class _CompactionRecordingSession(SimpleListSession): """Record the compaction bookkeeping a compaction-aware backend expects.""" @@ -1324,6 +1364,27 @@ async def run_compaction(self, args: Any = None) -> None: self.compactions.append(dict(args or {})) +@pytest.mark.asyncio +async def test_the_compaction_deferral_reads_the_settling_batch_not_the_callers_input() -> None: + # The batch settles through ``original_input``, so the deferral has to look there; + # but that slot also carries the caller's own turn input on every ordinary + # interruption save. Reading the whole slot would defer compaction for a response + # that produced no local tool output, purely because the caller resumed with an + # earlier one in its input. + from agents.run_internal.session_persistence import save_result_to_session + + session = _CompactionRecordingSession() + caller_input: list[TResponseInputItem] = [ + {"type": "function_call", "call_id": "call_EARLIER", "name": "t", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_EARLIER", "output": "old"}, + {"role": "user", "content": "go"}, + ] + + await save_result_to_session(session, caller_input, [], None, response_id="resp_fresh") + + assert [entry for entry in session.compactions if "deferred" in entry] == [] + + @pytest.mark.asyncio async def test_the_entry_settle_runs_the_compaction_bookkeeping() -> None: # The entry settle goes through the canonical persistence path, so a @@ -1387,6 +1448,41 @@ async def test_the_park_records_the_response_the_batch_belongs_to(streamed: bool assert pending["response_id"] == first.raw_responses[-1].response_id +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore:Pydantic serializer warnings:UserWarning") +async def test_a_rejected_max_turns_handler_output_keeps_the_held_record() -> None: + # The discard belongs to a handler that actually ends the run. Validation rejects a + # wrongly typed handler output by raising, and the streamed runner discards only + # after its finalization completes, so discarding ahead of the raise would leave + # the caller's live RunState without a batch its streamed twin still holds. + from agents.exceptions import UserError + from agents.run_internal.run_loop import finalize_max_turns_handler_output + + session = SimpleListSession() + agent = _make_deferring_agent() + agent.output_type = int + state = await _parked_and_approved(agent, session, streamed=False) + assert state._pending_session_write is not None + + async def _no_save(items: list[Any]) -> None: + return None + + with pytest.raises(UserError): + await finalize_max_turns_handler_output( + agent=agent, + hooks=RunHooks(), + run_config=RunConfig(tracing_disabled=True), + output="not an int", + context_wrapper=RunContextWrapper(context=None), + output_guardrail_results=[], + save_items_after_guardrails=_no_save, + include_in_history=False, + run_state=state, + ) + + assert state._pending_session_write is not None + + @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) async def test_a_max_turns_handler_completion_clears_the_held_record(streamed: bool) -> None: From b498f4b74215c8e4806a5129bfb66d189a4eaeef Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Mon, 7 Sep 2026 20:54:10 +0200 Subject: [PATCH 27/30] fix(sessions): dispose of the held batch when the run ends, not when its last step is chosen Choosing a terminal step is not the same as ending the run. Validation, the final-output hooks, the output guardrails and the final save all run after that choice, any of them can raise, and a run that raises may still be retried or reattached with the approved tool's call and output reachable only through the held batch. Consuming the batch at the choice threw it away on every one of those failures. Four sites carried that ordering, and they are the whole class: the max-turn handler finalization, the detached final output in both runners, and the detached final output on the resumed streamed loop. Each now disposes of the batch once finalization has completed, with a tripwire handled separately as the decided blocked outcome it is. The five remaining disposal sites are deliberate ones that follow an outcome already decided, and they are unchanged. Also: a re-interruption no longer overwrites the storage setting the parked response was produced under. Presence of the key decides, not its truthiness, so an ordinary ``store=None`` park keeps its own setting and the settle resolves that response's compaction mode from the right turn. ``response_id`` follows the same rule for the same reason. Tests pin the failure of each finalization stage in both runners, and the park storage settings across None, False and True. --- src/agents/run.py | 16 +- src/agents/run_internal/run_loop.py | 43 ++++-- .../run_internal/session_persistence.py | 14 +- ...test_deferred_interrupted_session_write.py | 140 ++++++++++++++++++ 4 files changed, 187 insertions(+), 26 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 74ad4c9d5a..cf0191353e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1973,12 +1973,6 @@ async def _save_max_turns_handler_output( try: if isinstance(turn_result.next_step, NextStepFinalOutput): - if session is None and run_state is not None: - # A detached completion has no Session to settle against - # and the run ends here, so the batch is discarded - # rather than left to invalidate the completed run's - # checkpoint. Mirrors the resumed final exit. - take_held_session_write(run_state) if run_state is not None and _has_output_guardrails( current_agent, run_config ): @@ -2106,6 +2100,16 @@ async def _save_max_turns_handler_output( if run_state is not None: run_state._terminal_unrecoverable = False + if session is None and run_state is not None: + # A detached completion has no Session to settle against + # and the run ends here, so the batch is discarded + # rather than left to invalidate the completed run's + # checkpoint. Only here, though: the guardrails and the + # final save above can raise, and a run that raises may + # still be retried or reattached, with the executed + # tool's call and output reachable only through it. + take_held_session_write(run_state) + # Ensure starting_input is not None and not RunState final_output_result_input: str | list[TResponseInputItem] = ( normalized_starting_input diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 509ce7af05..d812b30d7f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -784,12 +784,13 @@ async def finalize_max_turns_handler_output( A max-turn handler ends the run, so a held Session write still standing here has no later gate-legal exit to settle it: it is discarded, exactly as a detached completion discards it, so the finished run's checkpoint stays loadable and both - runners report the same terminal state. + runners report the same terminal state. The discard waits for the run to actually + end, which is either a completed finalization or a decided blocked outcome: + validation, the final-output hooks and the guardrails can all raise, and a run that + raises may still be retried or reattached, with the executed tool's call and output + reachable only through this batch. """ validated_output = validate_handler_final_output(agent, output) - # Only past the validation does the handler actually end the run; discarding above - # it would throw the batch away on a rejection the streamed runner survives. - take_held_session_write(run_state) output_text = format_final_output_text(agent, validated_output) synthesized_item = create_message_output_item(agent, output_text) @@ -805,6 +806,9 @@ async def finalize_max_turns_handler_output( output_guardrail_results, ) except OutputGuardrailTripwireTriggered: + # A blocked outcome is decided and nothing of the withheld batch may reach the + # Session, exactly as every other tripwire path disposes of it. + take_held_session_write(run_state) raise except Exception as guardrail_error: guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error) @@ -821,6 +825,7 @@ async def finalize_max_turns_handler_output( if redacted_persistence_error is not None: raise redacted_persistence_error from None + take_held_session_write(run_state) return validated_output, synthesized_item @@ -1552,11 +1557,6 @@ async def _save_max_turns_items( continue if isinstance(turn_result.next_step, NextStepFinalOutput): - if session is None: - # A detached final output has no Session to settle against - # and the run ends here, so the batch is discarded rather - # than left to invalidate the completed run's checkpoint. - take_held_session_write(run_state) await _finalize_streamed_final_output( streamed_result=streamed_result, agent=current_agent, @@ -1577,6 +1577,16 @@ async def _save_max_turns_items( ) if streamed_result._stored_exception is not None: break + if session is None: + # A detached final output has no Session to settle against + # and the run ends here, so the batch is discarded rather + # than left to invalidate the completed run's checkpoint. + # Only here, though: the finalization above runs the hooks, + # the guardrails and the final save, any of which can raise, + # and a run that raises may still be retried or reattached + # with the executed tool's call and output reachable only + # through this batch. + take_held_session_write(run_state) run_state._current_step = None break @@ -2056,12 +2066,6 @@ def _record_max_turns_handler_output( if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result): break elif isinstance(turn_result.next_step, NextStepFinalOutput): - if session is None: - # A detached completion has no Session to settle against and - # the run ends here, so the batch is discarded rather than - # left to invalidate the completed run's checkpoint. Mirrors - # the resumed final exit. - take_held_session_write(run_state) await _finalize_streamed_final_output( streamed_result=streamed_result, agent=current_agent, @@ -2078,6 +2082,15 @@ def _record_max_turns_handler_output( ) if streamed_result._stored_exception is not None: break + if session is None: + # A detached completion has no Session to settle against and + # the run ends here, so the batch is discarded rather than + # left to invalidate the completed run's checkpoint. Only here, + # though: the finalization above runs the hooks, the guardrails + # and the final save, any of which can raise, and a run that + # raises may still be retried or reattached with the executed + # tool's call and output reachable only through this batch. + take_held_session_write(run_state) if run_state is not None: run_state._current_step = None break diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 3882c8bab2..84f22f36f3 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1124,11 +1124,15 @@ def defer_interrupted_session_write( "held": True, # The response the withheld batch belongs to, so the settle can run the same # compaction bookkeeping the ordinary persistence path runs for it. An extend - # keeps the original response: the batch is that response's write. - "response_id": (pending.get("response_id") if pending is not None else None) or response_id, - "store": (pending.get("store") if pending is not None else None) - if (pending is not None and pending.get("store") is not None) - else store, + # keeps the original response: the batch is that response's write, and the + # settle resolves its compaction mode from that response's own storage setting. + # Presence decides, not truthiness: a park under the ordinary ``store=None`` + # records a real value, and letting a re-interruption's setting overwrite it + # would resolve the original response's compaction mode from the wrong turn. + "response_id": pending["response_id"] + if (pending is not None and "response_id" in pending) + else response_id, + "store": pending["store"] if (pending is not None and "store" in pending) else store, } run_state._pending_session_write = record diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index ad1f425ad8..1448fcc5ec 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections.abc import Callable from dataclasses import replace from typing import Any, Literal, cast @@ -1347,6 +1348,145 @@ async def test_the_settled_count_matches_what_the_append_actually_wrote() -> Non assert count == len(await session.get_items()) +class _FinalOutputHookFailure(RunHooks[Any]): + """Fail the run at the final-output hook, after the terminal step is decided.""" + + async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None: + raise RuntimeError("final output hook failed") + + +@pytest.mark.asyncio +async def test_a_failed_max_turns_finalization_keeps_the_held_record() -> None: + # The batch is disposed of when the run actually ends, not when the terminal step + # is chosen. Validation, the final-output hooks and the output guardrails all run + # after that choice and all can raise, and a run that raises may still be retried + # or reattached with the executed tool's call and output reachable only here. + from agents.run_internal.run_loop import finalize_max_turns_handler_output + + session = SimpleListSession() + agent = _make_deferring_agent() + state = await _parked_and_approved(agent, session, streamed=False) + assert state._pending_session_write is not None + + async def _no_save(items: list[Any]) -> None: + return None + + with pytest.raises(RuntimeError): + await finalize_max_turns_handler_output( + agent=agent, + hooks=_FinalOutputHookFailure(), + run_config=RunConfig(tracing_disabled=True), + output="stopped at max turns", + context_wrapper=RunContextWrapper(context=None), + output_guardrail_results=[], + save_items_after_guardrails=_no_save, + include_in_history=False, + run_state=state, + ) + + assert state._pending_session_write is not None + + +def _make_deferring_agent_with_a_turn_after_the_resume() -> Agent: + """A gated write whose resume runs one more model turn before finishing. + + The extra turn moves the final output past the resumed boundary and onto the main + loop, which owns its own detached-completion disposal. + """ + return Agent( + name="deferred repro (turn after resume)", + instructions="Always call write_thing.", + model=ScriptedModel( + [ + ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]), + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")] + ), + ModelStep(output=[function_call("look_up", {"query": "y"}, call_id="call_AFTER")]), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[look_up, write_thing], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize( + "make_agent", + [_make_deferring_agent, _make_deferring_agent_with_a_turn_after_the_resume], + ids=["final-on-the-resumed-turn", "final-on-a-later-turn"], +) +async def test_a_failed_detached_completion_keeps_the_held_record( + streamed: bool, make_agent: Callable[[], Agent] +) -> None: + # A detached completion discards the batch because the run ends there, but only + # once it has ended: the guardrails and the final save run after the terminal step + # is chosen, and a failure there leaves a checkpoint whose reattach is the batch's + # only remaining way into the Session. + from agents import output_guardrail + + @output_guardrail + async def _fails(ctx: Any, agent: Agent, output: Any) -> GuardrailFunctionOutput: + raise RuntimeError("output guardrail failed") + + session = SimpleListSession() + agent = make_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + assert state._pending_session_write is not None + agent.output_guardrails = [*agent.output_guardrails, _fails] + + with pytest.raises(RuntimeError): + await _run(agent, state, None, streamed=streamed) + + assert state._pending_session_write is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parked_store", [None, False, True]) +async def test_a_re_park_keeps_the_storage_setting_the_response_was_produced_under( + parked_store: bool | None, +) -> None: + # The batch belongs to the parked response, and the settle resolves that + # response's compaction mode from this value. Presence decides, not truthiness: a + # park under the ordinary ``store=None`` records a real setting, and a + # re-interruption under a different one must not overwrite it. + from agents.run_internal.session_persistence import defer_interrupted_session_write + + class _Session: + session_id = "s1" + + state = object.__new__(RunState) + state._pending_session_write = { + "session_id": "s1", + "items": [ + {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"} + ], + "before": None, + "persisted_count": 1, + "held": True, + "response_id": "resp_parked", + "store": parked_store, + } + state._current_turn_persisted_item_count = 0 + state._reasoning_item_id_policy = None + + defer_interrupted_session_write( + state, + _Session(), # type: ignore[arg-type] + run_items=[], + reasoning_item_id_policy=None, + response_id="resp_reinterrupted", + store=not parked_store, + ) + + assert state._pending_session_write is not None + assert state._pending_session_write["store"] is parked_store + assert state._pending_session_write["response_id"] == "resp_parked" + + class _CompactionRecordingSession(SimpleListSession): """Record the compaction bookkeeping a compaction-aware backend expects.""" From 58ac099d67105019916a201f401ac31d67568f72 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Tue, 8 Sep 2026 07:23:04 +0200 Subject: [PATCH 28/30] fix(sessions): count and classify the settling batch on the compaction path Two defects in how a settling held batch meets a compaction-aware session: - The compaction-deferral branch returned the run-item count alone, and it is the branch every held settle with outputs takes on such a backend, so exactly the sessions that defer were the ones whose settled turns undercounted. The count gates the resumed-safety refusal and slices later saves of the same turn, so it must equal what the append wrote. The branch now returns the combined count. - The local-continuation classification knew the mapped tool outputs but not the hosted MCP approval response, which is the locally produced half of its approval pair and must stay associated with the response chain that carried the request. Compacting that response before the model consumes the approval drops it in previous_response_id mode. The constant is now _LOCAL_CONTINUATION_OUTPUT_TYPES and covers both carriers: the settled dict and the MCPApprovalResponseItem the non-deferred resume commits, because classifying one and not the other would defer or compact the same response depending on which path persisted it. The four-stage scenario behind the count (partial approval, held settlement with a lapsed gate, gate re-enable, remaining approval) is pinned end to end: it must end in the documented fail-fast refusal with nothing duplicated. Each fix is also pinned at the unit boundary and proven red by mutation. --- .../run_internal/session_persistence.py | 19 ++- ...test_deferred_interrupted_session_write.py | 128 ++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 84f22f36f3..608ff71385 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -21,6 +21,7 @@ HandoffOutputItem, InputItem, ItemHelpers, + MCPApprovalResponseItem, ModelResponse, RunItem, ToolCallOutputItem, @@ -95,9 +96,14 @@ _SESSION_LIMIT_UNSET = object() -# Serialized item types that represent a locally produced tool output, i.e. the output -# kinds of the canonical call-to-output map. -_LOCAL_TOOL_OUTPUT_TYPES = frozenset(_TOOL_CALL_TO_OUTPUT_TYPE.values()) +# Serialized item types produced locally as the continuation of a model response: the +# output kinds of the canonical call-to-output map, plus the hosted MCP approval +# response, which is the locally produced half of its approval pair. Compaction for +# the response that carried the request must be deferred while any of these still +# needs to be associated with that response chain. +_LOCAL_CONTINUATION_OUTPUT_TYPES = frozenset(_TOOL_CALL_TO_OUTPUT_TYPE.values()) | { + "mcp_approval_response" +} async def admit_pending_input( @@ -776,11 +782,12 @@ async def save_result_to_session( # landed. Only a settle reads that slot: on an ordinary save it holds the # caller's input, whose earlier outputs say nothing about this response. has_local_tool_outputs = any( - isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items + isinstance(item, ToolCallOutputItem | HandoffOutputItem | MCPApprovalResponseItem) + for item in new_items ) or ( settling_held_batch and any( - isinstance(item, dict) and item.get("type") in _LOCAL_TOOL_OUTPUT_TYPES + isinstance(item, dict) and item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES for item in items_to_save ) ) @@ -797,7 +804,7 @@ async def save_result_to_session( "skip: deferring compaction for response %s due to local tool outputs", response_id, ) - return saved_run_items_count + return saved_run_items_count + settled_batch_items deferred_response_id = None get_deferred = getattr(session, "_get_deferred_compaction_response_id", None) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 1448fcc5ec..d3aa27294c 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1525,6 +1525,134 @@ async def test_the_compaction_deferral_reads_the_settling_batch_not_the_callers_ assert [entry for entry in session.compactions if "deferred" in entry] == [] +@pytest.mark.asyncio +async def test_the_settled_count_survives_the_compaction_deferral_branch() -> None: + # The deferral branch is the one every held settle with outputs takes on a + # compaction-aware backend, so returning the run-item count alone there reports a + # turn that persisted less than it wrote. That count gates the final sweep's + # re-append protection on a later gate-enabled resume. + from agents.run_internal.session_persistence import save_result_to_session + + session = _CompactionRecordingSession() + held: list[TResponseInputItem] = [ + {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "ok"}, + ] + + count = await save_result_to_session( + session, held, [], None, response_id="resp_parked", settling_held_batch=True + ) + + assert [entry for entry in session.compactions if "deferred" in entry] == [ + {"deferred": "resp_parked", "store": None} + ] + assert count == len(await session.get_items()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_partial_settle_on_a_compaction_session_still_fails_the_gated_resume_fast( + streamed: bool, +) -> None: + # Park two calls, approve one, and let the gate lapse for that resume so the batch + # settles into a compaction-aware session mid-run. Re-enable the gate and approve + # the rest: the settled turn's count must cover what the settle wrote, or the + # final sweep treats the turn as unpersisted and appends the stored items again. + from agents.exceptions import UserError + + session = _CompactionRecordingSession() + agent = _make_multi_approval_agent() + + first = await _run(agent, "go", session, streamed=streamed) + state = await _serialized_round_trip(first, agent) + state.approve( + next( + interruption + for interruption in state.get_interruptions() + if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" + ) + ) + gate = agent.output_guardrails + agent.output_guardrails = [] + second = await _run(agent, state, session, streamed=streamed) + assert len(second.interruptions) == 1 + agent.output_guardrails = gate + + state = await _serialized_round_trip(second, agent) + for interruption in state.get_interruptions(): + state.approve(interruption) + # The settled turn persisted items, so the re-enabled gate must refuse the resume + # outright; an undercounted turn is what would let it proceed and re-append the + # stored items through the final sweep. + with pytest.raises(UserError, match="output guardrails after current-turn items"): + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert _call_ids(items).count("call_PARKED") == 1 + assert _call_ids(items).count("call_PARKED_2") == 1 + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + # Only the second call may still be awaiting its output; nothing is duplicated. + assert outputs == {"call_PARKED"} + + +@pytest.mark.asyncio +async def test_a_held_mcp_approval_pair_defers_compaction_when_it_settles() -> None: + # The approval response is the locally produced half of its pair and must stay + # associated with the response chain that carried the request; compacting that + # response before the model consumes the approval drops it in + # ``previous_response_id`` mode. + from agents.run_internal.session_persistence import save_result_to_session + + session = _CompactionRecordingSession() + held: list[TResponseInputItem] = [ + { + "type": "mcp_approval_request", + "id": "mcpr_1", + "server_label": "srv", + "name": "do_it", + "arguments": "{}", + }, + {"type": "mcp_approval_response", "approval_request_id": "mcpr_1", "approve": True}, + ] + + count = await save_result_to_session( + session, held, [], None, response_id="resp_parked", settling_held_batch=True + ) + + assert [entry for entry in session.compactions if "deferred" in entry] == [ + {"deferred": "resp_parked", "store": None} + ] + assert [entry for entry in session.compactions if "response_id" in entry] == [] + assert count == len(await session.get_items()) + + +@pytest.mark.asyncio +async def test_an_ordinary_mcp_approval_response_defers_compaction_too() -> None: + # The non-deferred resume commits the approval response as a run item, and the + # classification must treat both carriers alike: deferring for the settled dict + # but not for the run item would leave the same response compacted or not + # depending on which path persisted it. + from agents.items import MCPApprovalResponseItem + from agents.run_internal.session_persistence import save_result_to_session + + session = _CompactionRecordingSession() + agent = _make_deferring_agent() + response_item = MCPApprovalResponseItem( + agent=agent, + raw_item={ + "type": "mcp_approval_response", + "approval_request_id": "mcpr_1", + "approve": True, + }, + ) + + await save_result_to_session(session, [], [response_item], None, response_id="resp_live") + + assert [entry for entry in session.compactions if "deferred" in entry] == [ + {"deferred": "resp_live", "store": None} + ] + + @pytest.mark.asyncio async def test_the_entry_settle_runs_the_compaction_bookkeeping() -> None: # The entry settle goes through the canonical persistence path, so a From 33651b1b45c3ef31dfd2fa1f506da651ddbac682 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Tue, 8 Sep 2026 10:35:08 +0200 Subject: [PATCH 29/30] fix(sessions): the held record owns the conversion policy of its items A detached re-park cannot see the Session backend, so it folded new items under the resuming run's own reasoning-id policy. For a Conversations-origin batch that strips the server id at the one point where nothing can restore it, and the reattach then drops the reasoning item as unpersistable. The park now records the conversion policy it actually used (None for a Conversations backend, the run's policy otherwise) on the held record, and a fold converts under the record's policy instead of the caller's. The key is gated and validated with the other held keys under the unreleased 1.18 schema, refused on ordinary pending writes and on unknown values; an absent key falls back to the caller's policy. Pinned at the fold and at the park, plus the validator rejection, each guard proven red by mutation. --- .../run_internal/session_persistence.py | 11 ++- src/agents/run_state.py | 28 ++++++- ...test_deferred_interrupted_session_write.py | 74 +++++++++++++++++++ tests/test_run_impl_resume_paths.py | 7 ++ 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 608ff71385..efee745c03 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1096,9 +1096,13 @@ def defer_interrupted_session_write( # The normal persistence path forces the reasoning-id policy to ``None`` for a # Conversations backend so a server-identified reasoning item stays persistable; - # the registration conversion must match or the sanitization later drops it. + # the registration conversion must match or the sanitization later drops it. A + # standing record owns the policy its items were converted under, so a re-park + # folds new items under the same conversion instead of the resuming run's own. if isinstance(session, OpenAIConversationsSession): reasoning_item_id_policy = None + if pending is not None and "reasoning_item_id_policy" in pending: + reasoning_item_id_policy = pending["reasoning_item_id_policy"] converted_run_items: list[TResponseInputItem] = [] for run_item in run_items: as_input = run_item_to_input_item(run_item, reasoning_item_id_policy) @@ -1140,6 +1144,7 @@ def defer_interrupted_session_write( if (pending is not None and "response_id" in pending) else response_id, "store": pending["store"] if (pending is not None and "store" in pending) else store, + "reasoning_item_id_policy": reasoning_item_id_policy, } run_state._pending_session_write = record @@ -1155,7 +1160,9 @@ def extend_held_session_write( With no Session attached the resolved turn's save is a no-op, so the executed tool output exists only in this process; folding it into the held batch lets the reattaching resume settle call and output together. Does nothing when no held - batch stands. + batch stands. The fold converts under the batch's registration policy, not the + caller's: a detached run cannot see the original backend, and a server reasoning + id stripped here could not be restored at the settle. """ if run_state is None or run_state._pending_session_write is None: return diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 3a98d09ea4..29a8027736 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -109,6 +109,7 @@ from .run_context import RunContextWrapper from .run_internal.items import ( NestedHistoryOwnedItemRef, + ReasoningItemIdPolicy, digest_input_item, ensure_nested_history_run_item_occurrence_key, nested_history_run_item_occurrence_key, @@ -180,6 +181,11 @@ class _PendingSessionWrite(TypedDict): ``response_id`` records the model response the withheld batch belongs to, and ``store`` the store setting that response was produced under, so the settle runs the same compaction bookkeeping the ordinary persistence path would have run for + ``reasoning_item_id_policy`` records how the batch's items were converted, so a + detached re-park folds new items under the same conversion: a Conversations-origin + batch preserves server reasoning ids even when the resuming run's own policy would + omit them, and an id stripped at registration cannot be restored at the settle. + it instead of appending behind its back. """ @@ -190,6 +196,7 @@ class _PendingSessionWrite(TypedDict): held: NotRequired[bool] response_id: NotRequired[str | None] store: NotRequired[bool | None] + reasoning_item_id_policy: NotRequired[ReasoningItemIdPolicy | None] def _default_run_state_validation_error( @@ -248,7 +255,8 @@ def _default_run_state_validation_error( ), "1.18": ( "Persists the interrupted turn's withheld Session write, including the response it " - "belongs to, so an approval resume can settle it under the output-guardrail gate." + "belongs to and the conversion policy its items were registered under, so an " + "approval resume can settle it under the output-guardrail gate." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -4403,7 +4411,11 @@ async def _build_run_state_from_json( for part in _HELD_PENDING_SESSION_WRITE_MIN_SCHEMA_VERSION.split(".", maxsplit=1) ) base_keys = {"session_id", "items", "before", "persisted_count"} - held_keys = {"held", "response_id", "store"} if held_keys_allowed else set() + held_keys = ( + {"held", "response_id", "store", "reasoning_item_id_policy"} + if held_keys_allowed + else set() + ) if ( (schema_major, schema_minor) < (1, 17) or not isinstance(state._current_step, NextStepRunAgain | NextStepInterruption) @@ -4420,12 +4432,20 @@ async def _build_run_state_from_json( and pending_write["store"] is not None and type(pending_write["store"]) is not bool ) - # Both keys describe the withheld response, so they are meaningless on an + or ( + "reasoning_item_id_policy" in pending_write + and pending_write["reasoning_item_id_policy"] not in (None, "preserve", "omit") + ) + # These keys describe the withheld batch, so they are meaningless on an # ordinary pending write and are refused there rather than restored as # state nothing consumes. or ( not pending_write.get("held") - and ("response_id" in pending_write or "store" in pending_write) + and ( + "response_id" in pending_write + or "store" in pending_write + or "reasoning_item_id_policy" in pending_write + ) ) or not isinstance(pending_write.get("session_id"), str) or not isinstance(pending_write.get("items"), list) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index d3aa27294c..845823a53b 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1487,6 +1487,80 @@ class _Session: assert state._pending_session_write["response_id"] == "resp_parked" +@pytest.mark.asyncio +async def test_a_detached_re_park_folds_under_the_batch_registration_policy() -> None: + # A Conversations-origin batch was converted preserving server reasoning ids. The + # detached re-park cannot see the backend, so it must fold under the policy the + # record carries rather than the resuming run's own: an id stripped here is + # unrecoverable and the reattach would drop the reasoning item as unpersistable. + from agents.items import ReasoningItem + from agents.run_internal.session_persistence import extend_held_session_write + + agent = _make_deferring_agent() + state = object.__new__(RunState) + state._pending_session_write = { + "session_id": "conv_abc", + "items": [ + {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"} + ], + "before": None, + "persisted_count": 1, + "held": True, + "response_id": "resp_parked", + "store": None, + "reasoning_item_id_policy": None, + } + state._current_turn_persisted_item_count = 0 + reasoning = ReasoningItem( + agent=agent, + raw_item={"id": "rs_SERVER_ID", "type": "reasoning", "summary": [], "content": []}, + ) + + extend_held_session_write(state, run_items=[reasoning], reasoning_item_id_policy="omit") + + items = state._pending_session_write["items"] + reasoning_ids = [i.get("id") for i in items if i.get("type") == "reasoning"] + assert reasoning_ids == ["rs_SERVER_ID"] + assert state._pending_session_write["reasoning_item_id_policy"] is None + + +@pytest.mark.asyncio +async def test_the_park_records_the_conversion_policy_it_used() -> None: + # The record owns how its items were converted. A Conversations park forces the + # preserving policy regardless of the run's own setting, and the recorded value is + # what a later detached fold must reuse. + from agents.items import ToolCallItem + from agents.memory.openai_conversations_session import OpenAIConversationsSession + from agents.run_internal.session_persistence import defer_interrupted_session_write + + session = object.__new__(OpenAIConversationsSession) + session._session_id = "conv_abc" + state = object.__new__(RunState) + state._pending_session_write = None + state._current_turn_persisted_item_count = 0 + call = ToolCallItem( + agent=_make_deferring_agent(), + raw_item={ + "type": "function_call", + "call_id": "call_PARKED", + "name": "t", + "arguments": "{}", + }, + ) + + defer_interrupted_session_write( + state, + session, + run_items=[call], + reasoning_item_id_policy="omit", + response_id="resp_parked", + store=None, + ) + + assert state._pending_session_write is not None + assert state._pending_session_write["reasoning_item_id_policy"] is None + + class _CompactionRecordingSession(SimpleListSession): """Record the compaction bookkeeping a compaction-aware backend expects.""" diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index de7203f010..6b3084af0f 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -508,6 +508,7 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( "held-with-before", "held-under-1-17", "held-keys-without-held", + "policy-shape", ], ) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: @@ -526,6 +527,12 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval # response_id and store describe the withheld response, so they are refused on # an ordinary pending write where nothing consumes them. payload["pending_session_write"]["response_id"] = "resp_1" + elif invalid == "policy-shape": + # The conversion-policy key only speaks the two policy literals or None; any + # other value would silently change how a fold converts the batch's items. + payload["pending_session_write"]["held"] = True + payload["pending_session_write"]["before"] = None + payload["pending_session_write"]["reasoning_item_id_policy"] = "banana" elif invalid == "held-under-1-17": # 1.17 defined the pending write as exactly four keys, so the held variant is # only readable under the version that introduced it. From 01700488e1cffff5f785b5edc11ffbfbbc12b86a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Tue, 8 Sep 2026 11:30:36 +0200 Subject: [PATCH 30/30] fix(sessions): the final sweep's direct settle speaks the settle dialect A reattached detached carry can reach the final exit with a zero persisted count, so the batch settles through the final sweep's direct save. That call armed the recovery registration but not the settle marking, so the compaction deferral could not see the batch's outputs when the final turn carried none of its own, and the returned count excluded what the settle wrote. One flag closes both, pinned at the helper boundary and proven red by mutation. Also repairs the held-record docstring, whose conversion-policy paragraph had split a sentence in two. --- .../run_internal/agent_runner_helpers.py | 1 + src/agents/run_state.py | 4 +- ...test_deferred_interrupted_session_write.py | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index ee7a71c1cf..2bd03322bd 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -648,6 +648,7 @@ async def save_final_turn_items_after_guardrails( # closed with the batch recorded instead of silently losing it, even when the # payload was deduplicated from the append. resumed_write_state=run_state if settling_held else None, + settling_held_batch=settling_held, ) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 29a8027736..f8564e63d5 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -181,12 +181,12 @@ class _PendingSessionWrite(TypedDict): ``response_id`` records the model response the withheld batch belongs to, and ``store`` the store setting that response was produced under, so the settle runs the same compaction bookkeeping the ordinary persistence path would have run for + it instead of appending behind its back. + ``reasoning_item_id_policy`` records how the batch's items were converted, so a detached re-park folds new items under the same conversion: a Conversations-origin batch preserves server reasoning ids even when the resuming run's own policy would omit them, and an id stripped at registration cannot be restored at the settle. - - it instead of appending behind its back. """ session_id: str diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 845823a53b..9dc2056040 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1727,6 +1727,45 @@ async def test_an_ordinary_mcp_approval_response_defers_compaction_too() -> None ] +@pytest.mark.asyncio +async def test_the_final_sweep_settle_defers_compaction_and_counts_what_it_wrote() -> None: + # A reattached detached carry can reach the final exit with a zero persisted + # count, so the batch settles through the final sweep's direct save. That save + # must speak the same settle dialect as every other one: the deferral must see + # the batch's outputs even when the final turn carries none of its own, and the + # returned count must cover what the append actually wrote. + from agents.items import MessageOutputItem + from agents.run_internal.agent_runner_helpers import save_final_turn_items_after_guardrails + from agents.testing.model import assistant_message + + session = _CompactionRecordingSession() + agent = _make_deferring_agent() + state = object.__new__(RunState) + state._pending_session_write = None + state._current_turn_persisted_item_count = 0 + state._reasoning_item_id_policy = None + state._current_step = None + held: list[TResponseInputItem] = [ + {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "ok"}, + ] + + count = await save_final_turn_items_after_guardrails( + session=session, + run_state=state, + session_persistence_enabled=True, + input_guardrail_results=[], + items=[MessageOutputItem(agent=agent, raw_item=assistant_message("done"))], + response_id="resp_final", + held_input=held, + ) + + assert [entry for entry in session.compactions if "deferred" in entry] == [ + {"deferred": "resp_final", "store": None} + ] + assert count == len(await session.get_items()) + + @pytest.mark.asyncio async def test_the_entry_settle_runs_the_compaction_bookkeeping() -> None: # The entry settle goes through the canonical persistence path, so a