From 0c58053fde10732215c15fdf9c33669df7d39762 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:14 +0200 Subject: [PATCH 01/38] 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 249ddfec62..592964a183 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1136,7 +1136,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: @@ -1187,7 +1209,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 e0a4011155..0e9e7206f3 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1344,6 +1344,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: @@ -1432,7 +1448,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, ) @@ -1475,7 +1491,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 4a5734930493fca04c65c336375f0dfda0494c9a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:14 +0200 Subject: [PATCH 02/38] 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 592964a183..647778b651 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -90,6 +90,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, @@ -1143,22 +1144,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 0e9e7206f3..04db7476ef 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -113,6 +113,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, @@ -1344,22 +1345,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 2fb6f36f36d83e074f8369a2f27b28fcdd9c6eb9 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:14 +0200 Subject: [PATCH 03/38] 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 647778b651..26be88db47 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1145,8 +1145,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 04db7476ef..bb1ef4ea5f 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1346,8 +1346,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 3123ef0d9161493a0a4dbebdfc9db388df3e7f6c Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:15 +0200 Subject: [PATCH 04/38] 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 bb1ef4ea5f..4dbbe38cea 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1408,13 +1408,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 bcc2cb70c5cff61c1af6cadfb21dedf1ef68b1cf Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:15 +0200 Subject: [PATCH 05/38] 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 26be88db47..78536d46f5 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -90,7 +90,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, @@ -984,6 +984,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( @@ -1144,7 +1148,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 @@ -1152,6 +1157,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: @@ -1190,6 +1201,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) @@ -1268,6 +1282,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( @@ -1397,7 +1414,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 4dbbe38cea..e3b99ddc77 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -113,7 +113,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, @@ -177,6 +176,7 @@ _session_get_items, admit_pending_input, commit_server_pending_input, + deferred_interrupted_session_prefix, persist_session_items_for_guardrail_trip, prepare_compaction_model_input, prepare_input_with_session, @@ -1203,6 +1203,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, @@ -1345,10 +1349,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) @@ -1443,8 +1449,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, ) @@ -1469,7 +1481,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 @@ -1486,8 +1503,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 c26c708648..676a548a5b 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", @@ -844,6 +845,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 4ccda5e966c6db5955312bdaf029bbc3b83694d1 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:15 +0200 Subject: [PATCH 06/38] 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 676a548a5b..15352fe92c 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", @@ -867,33 +871,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 530c121df3ae873098027d862535821413f13b7b Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:15 +0200 Subject: [PATCH 07/38] 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 15352fe92c..87d939509b 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, @@ -906,16 +907,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 8e75e93abac789b228bfa828019e3824de41280c Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:16 +0200 Subject: [PATCH 08/38] 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 6a1582ee31f700348c0807f66a9ccaff0fbc635d Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:16 +0200 Subject: [PATCH 09/38] 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 78536d46f5..dfe59aba32 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 @@ -984,10 +986,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( @@ -1141,29 +1139,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: @@ -1198,37 +1174,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. @@ -1282,9 +1283,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( @@ -1364,6 +1362,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, @@ -1407,6 +1411,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, @@ -1414,12 +1419,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, @@ -1436,6 +1436,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, @@ -2111,21 +2115,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, @@ -2158,6 +2177,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 103d2af444..4eee4b5e4b 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 @@ -468,8 +469,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 @@ -500,6 +506,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 @@ -594,9 +606,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 @@ -609,11 +627,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 e3b99ddc77..eb2c7f6f34 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -176,7 +176,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_compaction_model_input, prepare_input_with_session, @@ -188,6 +189,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 @@ -396,11 +398,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, @@ -563,6 +573,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) @@ -1203,10 +1218,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, @@ -1349,13 +1360,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: @@ -1411,23 +1415,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), @@ -1449,14 +1474,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, ) @@ -1474,6 +1508,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, @@ -1481,12 +1520,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 @@ -1503,14 +1537,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, ) @@ -1976,17 +2019,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 87d939509b..35b49c035e 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", @@ -244,6 +241,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, @@ -257,6 +277,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 @@ -827,13 +853,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, @@ -850,89 +885,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( @@ -950,6 +1020,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 982f6564fd..b15ac430db 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, @@ -169,12 +169,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( @@ -228,7 +237,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." ), "1.18": ( "Binds restored local MCP calls to their configured server and original tool name, " @@ -4503,7 +4513,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 3a4115a23089b3e9d9f4a8288360172275c40591 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:16 +0200 Subject: [PATCH 10/38] 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 05dacb0145..4750779037 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" @@ -510,12 +512,36 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval for entry in payload["context"].pop("function_tool_approvals", []): payload["context"]["approvals"][entry["tool_key"]] = entry["decision"] 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 c55f1ac5c21a4dbf23758d19ad33dc8bf7951b02 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:16 +0200 Subject: [PATCH 11/38] 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 dfe59aba32..5bc6d1cf18 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 @@ -2115,7 +2115,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() ): @@ -2133,7 +2132,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, @@ -2177,7 +2176,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 4eee4b5e4b..fd9081c857 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 @@ -469,13 +469,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 @@ -510,8 +505,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 35b49c035e..ac85ed3016 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -886,13 +886,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, @@ -905,11 +905,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") @@ -938,7 +938,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 @@ -946,20 +946,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 ed7445c3923c7bc60700da35a62937576a0048ca Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:17 +0200 Subject: [PATCH 12/38] 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 c002237603d44eb7a913dda1574b264184214369 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:17 +0200 Subject: [PATCH 13/38] 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 eb2c7f6f34..e69479a685 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -2035,9 +2035,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 ac85ed3016..83f85ba48b 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -934,9 +934,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 4750779037..0a5a413769 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 ee119ad142095f8a0a47dd70a3c50ca925d0bcee Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:17 +0200 Subject: [PATCH 14/38] 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 5bc6d1cf18..fa60447f75 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -2124,14 +2124,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, @@ -2139,8 +2143,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 81794759a4c56bc9a23337c164315c10504a1667 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:17 +0200 Subject: [PATCH 15/38] 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 83f85ba48b..cdf7991388 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -866,6 +866,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 [], @@ -885,6 +887,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, @@ -1019,12 +1055,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 b09b8e04b5ad4e7f283f5bf36cbe65d51bd86695 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:18 +0200 Subject: [PATCH 16/38] 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 cdf7991388..23966efc7f 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -888,7 +888,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 5284e0165495127d4f4569e8308ac36e4fb7a76a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:18 +0200 Subject: [PATCH 17/38] 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 fa60447f75..952e12d3df 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1955,6 +1955,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 ): @@ -2155,6 +2161,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 e69479a685..553960ce6c 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1985,6 +1985,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, @@ -2023,7 +2029,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 23966efc7f..9357384192 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -867,7 +867,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 [], @@ -887,10 +892,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. @@ -900,12 +920,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": @@ -1070,6 +1097,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 e2518e93b6..d130da398e 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4314,6 +4314,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( @@ -4325,6 +4326,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 2d3bed342c2671ee567b6d7baa77470cba6a4b0b Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:18 +0200 Subject: [PATCH 18/38] 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 952e12d3df..01aa15d174 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -2136,16 +2136,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 fd9081c857..601dd337e7 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -620,6 +620,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 @@ -632,6 +633,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 9357384192..f948bdfe73 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -885,7 +885,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 ), ) @@ -952,7 +958,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: @@ -971,19 +976,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) @@ -992,9 +993,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)] @@ -1099,7 +1098,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 d9da2561028b68c17b9803903c78c8147c92ef10 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:18 +0200 Subject: [PATCH 19/38] 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 601dd337e7..2142a778fa 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 @@ -611,6 +615,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 f948bdfe73..90023c2480 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, @@ -866,6 +867,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, @@ -898,16 +905,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 @@ -932,26 +985,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 68156a570ad1b4a4175e279ab32f9391643f1253 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:18 +0200 Subject: [PATCH 20/38] 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 90023c2480..542028b579 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, @@ -902,49 +903,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 @@ -985,24 +966,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 @@ -1064,6 +1065,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 58ee1f45522b187c9df91808fe7b7f743edbc372 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:19 +0200 Subject: [PATCH 21/38] 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 2142a778fa..4698f897a5 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, @@ -615,13 +617,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, @@ -634,6 +636,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 [], @@ -644,8 +657,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 542028b579..2ebca66653 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -868,6 +868,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. @@ -898,7 +903,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 8351ae766304e060ed589fbca33804397306015b Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:19 +0200 Subject: [PATCH 22/38] 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 ++ ...test_deferred_interrupted_session_write.py | 255 ++++++++++++++++-- tests/test_run_impl_resume_paths.py | 17 +- 11 files changed, 486 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 01aa15d174..d680a0159b 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1205,6 +1205,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 = ( @@ -1614,6 +1615,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 @@ -2162,6 +2164,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 553960ce6c..c4f4765dd1 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -779,8 +779,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) @@ -1442,6 +1450,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: @@ -1761,6 +1770,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 @@ -2052,6 +2066,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 2ebca66653..28df2911b3 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1045,6 +1045,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. @@ -1105,6 +1106,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 @@ -1188,23 +1193,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 81c0bf0ef0..55a00fd0fa 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -154,6 +154,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, @@ -2543,6 +2544,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 b15ac430db..431beb0723 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -177,6 +177,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 @@ -184,6 +188,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( @@ -204,6 +209,7 @@ def _default_run_state_validation_error( _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.", @@ -237,13 +243,14 @@ 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": ( "Binds restored local MCP calls to their configured server and original tool name, " - "preserves independent apply_patch approval scopes, and binds function-tool approval " - "decisions to their owning agent." + "preserves independent apply_patch approval scopes, binds function-tool approval " + "decisions to their owning agent, and 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) @@ -4509,13 +4516,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/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 0a5a413769..e19941243f 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" @@ -514,6 +517,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. @@ -524,14 +533,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) From 6572198b5e38c042b1c208c6f591586e973838d7 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:19 +0200 Subject: [PATCH 23/38] 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 d680a0159b..a0fff2aa54 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1206,6 +1206,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 = ( @@ -2165,6 +2166,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 c4f4765dd1..38b86db650 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1451,6 +1451,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: @@ -2067,6 +2068,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 28df2911b3..524ccd038b 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()) + def prepare_compaction_model_input( session: Session | None, @@ -795,8 +799,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) @@ -1046,6 +1058,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. @@ -1110,6 +1123,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 @@ -1196,6 +1212,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 @@ -1209,6 +1226,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 431beb0723..e1a36ad342 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -178,9 +178,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 @@ -189,6 +190,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( @@ -4524,7 +4526,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) @@ -4536,6 +4538,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 b9239c327dab379b12bc47e87c1c7a1666059a25 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:19 +0200 Subject: [PATCH 24/38] 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 524ccd038b..3993819be7 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1219,7 +1219,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 e1a36ad342..23f9dbb222 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -4518,9 +4518,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) @@ -4543,6 +4545,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 7880fef0e2749b8302ce03675e236f21cb8d81b8 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:20 +0200 Subject: [PATCH 25/38] 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 e19941243f..a6a0ccb70d 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) @@ -517,6 +524,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 c4304344c88a27c9427e392811cbf5fea894db26 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:20 +0200 Subject: [PATCH 26/38] 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 a0fff2aa54..5fa1ab9441 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, ) @@ -1227,11 +1228,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 38b86db650..d10fbaa79d 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -189,6 +189,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, ) @@ -788,8 +789,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) @@ -1457,7 +1460,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, @@ -1498,7 +1519,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, @@ -1561,7 +1598,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 3993819be7..515d5cd59d 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", @@ -680,13 +681,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 @@ -798,17 +808,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) @@ -854,7 +872,7 @@ async def save_result_to_session( if compaction_wrapper is not None: compaction_wrapper._session_compaction_is_automatic = False # type: ignore[attr-defined] - return saved_run_items_count + return saved_run_items_count + settled_batch_items async def save_resumed_turn_items( @@ -907,6 +925,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 @@ -923,8 +942,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: @@ -1231,6 +1284,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 6206c4527b9a5c68a4c07940ff2903ecfe22e578 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:20 +0200 Subject: [PATCH 27/38] 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 5fa1ab9441..bf07827650 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1974,12 +1974,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 ): @@ -2107,6 +2101,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 d10fbaa79d..c0110790ef 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -787,12 +787,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) @@ -808,6 +809,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) @@ -824,6 +828,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 @@ -1555,11 +1560,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, @@ -1580,6 +1580,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 @@ -2053,12 +2063,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, @@ -2075,6 +2079,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 515d5cd59d..e42d287302 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1174,11 +1174,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 f365e8b19bfc3a358b3388d472ee91ba4a7169c0 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:20 +0200 Subject: [PATCH 28/38] 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 e42d287302..e44da22b37 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" +} def prepare_compaction_model_input( @@ -820,11 +826,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 ) ) @@ -841,7 +848,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 32a8ad875cf38b7261fa04fa4825103227c9e125 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:21 +0200 Subject: [PATCH 29/38] 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 | 30 ++++++-- ...test_deferred_interrupted_session_write.py | 74 +++++++++++++++++++ tests/test_run_impl_resume_paths.py | 7 ++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index e44da22b37..3922b88717 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -1146,9 +1146,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) @@ -1190,6 +1194,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 @@ -1205,7 +1210,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 23f9dbb222..5dedce781d 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -110,6 +110,7 @@ from .run_context import RunContextWrapper, _ApprovalRecord, _FunctionToolApprovalKey from .run_internal.items import ( NestedHistoryOwnedItemRef, + ReasoningItemIdPolicy, digest_input_item, ensure_nested_history_run_item_occurrence_key, nested_history_run_item_occurrence_key, @@ -181,6 +182,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. """ @@ -191,6 +197,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( @@ -251,8 +258,9 @@ def _default_run_state_validation_error( "Binds restored local MCP calls to their configured server and original tool name, " "preserves independent apply_patch approval scopes, binds function-tool approval " "decisions to their owning agent, and 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." + "Session write, including the response it 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) @@ -4528,7 +4536,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) @@ -4545,12 +4557,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 a6a0ccb70d..8edae3d19f 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: @@ -528,6 +529,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 f10b1b019e0e222941533a9c0c378134a645744a Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:21 +0200 Subject: [PATCH 30/38] 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 4698f897a5..92683557b8 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -660,6 +660,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 5dedce781d..654fa35dc3 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -182,12 +182,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 From b3d902f7818e303652a14d9a5e7706e2a43f17bb Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 08:55:21 +0200 Subject: [PATCH 31/38] fix(sessions): held settlement derives pairing evidence from the filtered session view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``HandoffInputData.new_items`` is the session-history axis by contract, and ``input_items`` exists precisely so a filter can shape model input while preserving history. The held settle defeated that: an output the commit boundary folded into the batch settled on the batch's own evidence, so a filter that removed a complete pair from ``new_items`` — the library's own ``remove_all_tools`` included — found it persisted anyway. The commit boundary now records the call ids it folds in a deliberately transient, per-turn set on the run state. A settling or folding exit whose ``run_items`` are the resolved session view drops the batch's copy of those outputs: kept ones arrive through the view itself, removed ones must not land. The set resets at each turn boundary, because the filter's authority covers one turn, and it does not serialize, because an output folded by an earlier process is carried prior-turn history that a later turn's filter is not entitled to remove, exactly as the eager path cannot unpersist earlier turns. No schema change. The regression pair Sylvester asked for is pinned in both runners, plus the library-filter secret case, the detached filtered handoff riding a checkpoint, and the carried-pair boundary. Two prior tests asserted the settled-from-batch behaviour and now pin the corrected contract. --- src/agents/run.py | 14 +- .../run_internal/agent_runner_helpers.py | 3 + src/agents/run_internal/run_loop.py | 43 +++-- .../run_internal/session_persistence.py | 68 ++++++-- src/agents/run_internal/turn_resolution.py | 7 + src/agents/run_state.py | 12 ++ ...test_deferred_interrupted_session_write.py | 164 +++++++++++++++++- 7 files changed, 269 insertions(+), 42 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index bf07827650..3b86e117a1 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1187,6 +1187,7 @@ def _mark_response_hooks_started() -> None: extend_held_session_write( run_state, run_items=turn_session_items, + run_items_are_the_session_view=True, reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), @@ -1228,11 +1229,10 @@ def _mark_response_hooks_started() -> None: ) ) else: - # 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. + # An emptied resolved turn settles what the session + # view left the batch: outputs the filter removed + # this turn dropped with their calls at the fold + # boundary, and carried prior-turn pairs still land. run_state._current_turn_persisted_item_count = ( await settle_held_batch_for_emptied_turn( run_state, @@ -1677,6 +1677,9 @@ async def _save_max_turns_handler_output( not resuming_turn or isinstance(run_state._current_step, NextStepRunAgain) ): run_state._current_turn_persisted_item_count = 0 + # A handoff filter's session authority covers one turn, so the + # folded-output record resets with the turn it described. + run_state._held_output_call_ids_folded_this_turn.clear() logger.debug("Running agent %s (turn %s)", current_agent.name, current_turn) @@ -2206,6 +2209,7 @@ async def _save_max_turns_handler_output( extend_held_session_write( run_state, run_items=session_items_for_turn(turn_result), + run_items_are_the_session_view=True, reasoning_item_id_policy=(run_state._reasoning_item_id_policy), ) append_model_response_if_new( diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 92683557b8..9a1ccc6aef 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -646,6 +646,9 @@ async def save_final_turn_items_after_guardrails( items, reasoning_item_id_policy, pending_call_ids=_pending_approval_call_ids(run_state), + folded_output_call_ids=( + run_state._held_output_call_ids_folded_this_turn if run_state is not None else None + ), ) return await save_result_to_session( session, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index c0110790ef..e343585930 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -1442,6 +1442,7 @@ async def _save_max_turns_items( extend_held_session_write( run_state, run_items=turn_session_items, + run_items_are_the_session_view=True, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1465,10 +1466,9 @@ async def _save_max_turns_items( elif turn_session_items: reinterruption_items = list(turn_session_items) else: - # 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. + # An emptied resolved turn settles what the session view + # left the batch: filtered outputs are gone with their + # calls, carried prior-turn pairs still land. streamed_result._current_turn_persisted_item_count = ( await settle_held_batch_for_emptied_turn( run_state, @@ -1510,22 +1510,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) - # 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. + # A detached exit folds the resolved session view into the + # held batch, and the fold drops the batch's copies of this + # turn's outputs so the view decides what rides to the + # reattach. if session is None: extend_held_session_write( run_state, run_items=turn_session_items, + run_items_are_the_session_view=True, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), ) elif not turn_session_items: - # An emptied resolved turn still settles the pairs the - # batch already holds; only the unpaired requests drop. + # An emptied resolved turn settles what the session view + # left the batch: filtered outputs are gone with their + # calls, carried prior-turn pairs still land. streamed_result._current_turn_persisted_item_count = ( await settle_held_batch_for_emptied_turn( run_state, @@ -1594,22 +1595,23 @@ async def _save_max_turns_items( break if isinstance(turn_result.next_step, NextStepRunAgain): - # 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. + # A detached exit folds the resolved session view into the + # held batch, and the fold drops the batch's copies of this + # turn's outputs so the view decides what rides to the + # reattach. if session is None: extend_held_session_write( run_state, run_items=turn_session_items, + run_items_are_the_session_view=True, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), ) elif not turn_session_items: - # An emptied resolved turn still settles the pairs the - # batch already holds; only the unpaired requests drop. + # An emptied resolved turn settles what the session view + # left the batch: filtered outputs are gone with their + # calls, carried prior-turn pairs still land. streamed_result._current_turn_persisted_item_count = ( await settle_held_batch_for_emptied_turn( run_state, @@ -1722,6 +1724,9 @@ async def _save_max_turns_items( streamed_result._current_turn_persisted_item_count = 0 if run_state is not None: run_state._current_turn_persisted_item_count = 0 + # A handoff filter's session authority covers one turn, so the + # folded-output record resets with the turn it described. + run_state._held_output_call_ids_folded_this_turn.clear() if max_turns is not None and current_turn > max_turns: _error_tracing.attach_error_to_span( @@ -2037,6 +2042,7 @@ def _record_max_turns_handler_output( streamed_result._current_turn_persisted_item_count = 0 if run_state is not None: run_state._current_turn_persisted_item_count = 0 + run_state._held_output_call_ids_folded_this_turn.clear() if server_conversation_tracker is not None: server_conversation_tracker.track_server_items(turn_result.model_response) @@ -2118,6 +2124,7 @@ def _record_max_turns_handler_output( extend_held_session_write( run_state, run_items=turn_session_items, + run_items_are_the_session_view=True, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), ) elif parked_items_deferred and await _should_persist_stream_items( diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 3922b88717..82a221e375 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -922,6 +922,9 @@ async def save_resumed_turn_items( items, reasoning_item_id_policy, pending_call_ids=_pending_approval_call_ids(run_state), + folded_output_call_ids=( + run_state._held_output_call_ids_folded_this_turn if run_state is not None else None + ), ) saved_count = await save_result_to_session( session, @@ -965,14 +968,14 @@ async def settle_held_batch_for_emptied_turn( 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. + """Settle what an emptied resolved turn's session view left in the held batch. + + A handoff ``input_filter`` can drop every resolved item, and ``new_items`` is the + session-history axis by contract: an output the commit boundary folded this turn + is dropped with its call before this settle sees the batch, so a filtered pair + stays out of the Session. What still settles is carried prior-turn history (a + detached carry riding a checkpoint), which a later turn's filter is not entitled + to remove, under the same pairing rules as every other settle. """ return await save_resumed_turn_items( run_state=run_state, @@ -1028,15 +1031,20 @@ def _held_items_safe_to_settle( reasoning_item_id_policy: ReasoningItemIdPolicy | None, *, pending_call_ids: set[str] | None = None, + folded_output_call_ids: set[str] | None = 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. + Pairing is the safety predicate, and pairing evidence must come from the resolved + session view, not from the batch itself: ``HandoffInputData.new_items`` is the + session-history axis by contract, so an output the commit boundary folded into the + batch this run (``folded_output_call_ids``) settles only when the view kept it. A + dropped output takes its call with it, and a kept output keeps its call. Outputs + folded by an earlier process are carried prior-turn history and are not the + filter's to remove, exactly as the eager path cannot unpersist earlier turns. ``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 @@ -1044,6 +1052,21 @@ def _held_items_safe_to_settle( non-deferred park persists a call before its output exists. """ pending_call_ids = pending_call_ids or set() + if folded_output_call_ids: + # An output the commit boundary folded this turn belongs to the resolved + # session view: when the view kept it, it arrives through ``run_items`` in + # this very save, and when the filter removed it, it must not settle from the + # batch. Either way the batch's copy is not pairing evidence, so it drops + # unconditionally and the view decides what lands. + held_items = [ + item + for item in held_items + if not ( + isinstance(item, dict) + and item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES + and item.get("call_id") in folded_output_call_ids + ) + ] 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 @@ -1119,6 +1142,7 @@ def defer_interrupted_session_write( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, response_id: str | None = None, store: bool | None = None, + run_items_are_the_session_view: bool = False, ) -> None: """Register the interruption's withheld batch as a held pending Session write. @@ -1161,6 +1185,26 @@ 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 [] + if ( + run_items_are_the_session_view + and pending is not None + and run_state._held_output_call_ids_folded_this_turn + ): + # A detached exit folds through this merge, and the filter's view is exactly + # ``run_items``: the batch's copy of an output this turn folded is never + # pairing evidence, because a kept output rides back in through the view in + # this same merge and a removed one must not reach the reattach. An unpaired + # call this leaves behind is the entry settle's to prune, with the full + # batch-plus-view pairing in hand. Parks and re-parks are unaffected: their + # view always carries their own outputs (measured, nested history included). + base_items = [ + item + for item in base_items + if not ( + item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES + and item.get("call_id") in run_state._held_output_call_ids_folded_this_turn + ) + ] 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] @@ -1204,6 +1248,7 @@ def extend_held_session_write( *, run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + run_items_are_the_session_view: bool = False, ) -> None: """Fold a detached exit's resolved items into the standing held batch. @@ -1223,6 +1268,7 @@ def extend_held_session_write( None, run_items=run_items, reasoning_item_id_policy=reasoning_item_id_policy, + run_items_are_the_session_view=run_items_are_the_session_view, ) diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 55a00fd0fa..43fbe0e204 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -2550,6 +2550,13 @@ def _commit_tool_output(item: RunItem) -> None: # ``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. + folded_call_id = extract_tool_call_id(getattr(item, "raw_item", None)) + if folded_call_id: + # Only this run can tell a current-turn folded output from carried + # prior-turn history, and a handoff filter's session authority covers + # exactly the current turn. The committer only ever folds the function + # family (measured), so the raw call id is the right key. + run_state._held_output_call_ids_folded_this_turn.add(folded_call_id) extend_held_session_write( run_state, run_items=[item], diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 654fa35dc3..ff66f455f1 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -911,6 +911,14 @@ class RunState(Generic[TContext, TAgent]): _session_write_in_progress: bool = field(default=False, repr=False) """Live ownership guard; independent serialized copies require caller serialization.""" + _held_output_call_ids_folded_this_turn: set[str] = field(default_factory=set, repr=False) + """Call ids of tool outputs the commit boundary folded into the held batch this + turn. Deliberately transient and reset at each turn boundary: a handoff filter's + authority over session history covers exactly one turn, and only the live run can + tell a folded output of the current turn from carried prior-turn history. After a + checkpoint the set is empty, so a reattaching entry settle keeps the carried batch + whole.""" + _terminal_unrecoverable: bool = field(default=False, repr=False) """Set once a final output, its guardrails, and its terminal hooks have all completed. @@ -960,6 +968,7 @@ def __init__( self._schema_version = CURRENT_SCHEMA_VERSION self._pending_session_write = None self._session_write_in_progress = False + self._held_output_call_ids_folded_this_turn = set() self._terminal_unrecoverable = False from .agent_tool_state import get_agent_tool_state_scope @@ -970,6 +979,9 @@ def _copy_for_result_checkpoint(self) -> RunState[TContext, TAgent]: copied = copy.copy(self) copied._pending_session_write = copy.deepcopy(self._pending_session_write) copied._session_write_in_progress = False + copied._held_output_call_ids_folded_this_turn = set( + self._held_output_call_ids_folded_this_turn + ) if self._context is None: return copied copied._context = self._context._copy_for_run_state() diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 9dc2056040..389d792b03 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -121,6 +121,41 @@ def _make_multi_approval_agent( _PREAMBLE_TEXT = "About to write the thing." +@function_tool(needs_approval=True) +async def read_secret(query: str) -> str: + return "SECRET-VALUE-42" + + +def _make_secret_handoff_agent(input_filter: Any) -> Agent: + """A gated secret-bearing tool resolved into a handoff with the given filter.""" + from agents import handoff + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + return Agent( + name="deferred repro (secret)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("read_secret", {"query": "x"}, call_id="call_SECRET"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[read_secret], + handoffs=[handoff(target, input_filter=input_filter)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + def _make_terminal_tool_agent( *, with_guardrails: bool = True, @@ -523,14 +558,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_settles_the_paired_part_of_the_held_batch( +async def test_an_emptied_resolved_turn_honors_the_filters_history_authority( streamed: bool, ) -> None: - # 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. + # ``HandoffInputData.new_items`` is the session-history axis by contract, so a + # filter that empties it is asking for nothing of this turn to persist. The + # approved tool ran, but persisting its pair from the held record would defeat the + # documented filter contract; callers who want the record keep ``new_items`` and + # filter model input through ``input_items`` instead. session = SimpleListSession() agent = _make_emptying_handoff_agent() state = await _parked_and_approved(agent, session, streamed=streamed) @@ -541,8 +576,8 @@ async def test_an_emptied_resolved_turn_settles_the_paired_part_of_the_held_batc 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 "call_PARKED" not in calls, "the filter removed the pair from session history" + assert "call_HANDOFF" not in 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. @@ -572,6 +607,7 @@ async def test_a_filter_that_drops_one_output_takes_its_held_call_with_it( 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 "call_PARKED_2" not in calls, "the filtered output must not settle from the batch" assert "pending_session_write" not in resumed.to_state().to_json() assert state._pending_session_write is None @@ -921,6 +957,9 @@ async def test_entry_settle_drops_a_held_call_the_filter_unpaired() -> None: 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))}" + # The filter kept this pair, so the batch must deliver it to the reattach; losing + # it silently would look symmetric too. + assert "call_PARKED" in calls and "call_PARKED" in outputs @pytest.mark.asyncio @@ -1472,6 +1511,7 @@ class _Session: } state._current_turn_persisted_item_count = 0 state._reasoning_item_id_policy = None + state._held_output_call_ids_folded_this_turn = set() defer_interrupted_session_write( state, @@ -1561,6 +1601,113 @@ async def test_the_park_records_the_conversion_policy_it_used() -> None: assert state._pending_session_write["reasoning_item_id_policy"] is None +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_librarys_own_filter_keeps_the_secret_out_of_the_session( + streamed: bool, +) -> None: + # ``remove_all_tools`` filters both ``new_items`` and ``input_items``: it wants + # tool data out of session history too. The held settle must not put back what the + # library's own filter removed. + from agents.extensions.handoff_filters import remove_all_tools + + from .test_deferred_interrupted_session_write import _make_secret_handoff_agent + + session = SimpleListSession() + agent = _make_secret_handoff_agent(remove_all_tools) + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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 == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_input_items_only_filter_preserves_the_pair_in_session( + streamed: bool, +) -> None: + # ``input_items`` is the model-input axis: filtering it says nothing about session + # history, so the executed pair persists exactly as an unfiltered handoff would. + from agents import HandoffInputData + + def input_only(data: HandoffInputData) -> HandoffInputData: + return data.clone(input_items=()) + + session = SimpleListSession() + agent = _make_secret_handoff_agent(input_only) + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert any("SECRET-VALUE-42" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_SECRET" in calls and "call_SECRET" in outputs + assert calls - outputs == set() + + +@pytest.mark.asyncio +async def test_a_detached_filtered_handoff_drops_the_pair_before_the_reattach() -> None: + # The filter's authority does not lapse because the resume ran detached: the fold + # happened in this process, so the exit can still tell a current-turn output from + # carried history, and the batch must not smuggle the filtered pair to the + # reattaching entry settle. Cancellation only exists on the streaming runner. + from agents.extensions.handoff_filters import remove_all_tools + + session = SimpleListSession() + agent = _make_secret_handoff_agent(remove_all_tools) + 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, "the after-turn cancel must leave the batch riding" + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in pending["items"]), ( + "the filtered secret must not ride the checkpoint to the reattach" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_carried_pair_survives_a_filtered_handoff_on_a_later_run( + streamed: bool, +) -> None: + # After a checkpoint the folded set is empty on purpose: an output folded by an + # earlier process is carried prior-turn history, which a later turn's filter is + # not entitled to remove, exactly as the eager path cannot unpersist earlier + # turns. The reattaching entry settle keeps the carried batch whole. + 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 + assert {item.get("type") for item in pending["items"]} >= { + "function_call", + "function_call_output", + } + + state = await RunState.from_json(agent, json.loads(json.dumps(checkpoint))) + reattached = await _run(agent, state, session, streamed=streamed) + assert reattached.final_output == "done" + items = await session.get_items() + assert _parked_pair(items) == _EXPECTED_PAIR + + class _CompactionRecordingSession(SimpleListSession): """Record the compaction bookkeeping a compaction-aware backend expects.""" @@ -1744,6 +1891,7 @@ async def test_the_final_sweep_settle_defers_compaction_and_counts_what_it_wrote state._pending_session_write = None state._current_turn_persisted_item_count = 0 state._reasoning_item_id_policy = None + state._held_output_call_ids_folded_this_turn = set() state._current_step = None held: list[TResponseInputItem] = [ {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, From 85b061c7625635fff712b8a1c6fec7d462c797c2 Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 10:15:07 +0200 Subject: [PATCH 32/38] fix(sessions): fold ownership rides the held record so the filter contract survives a serialized retry A post-output callback crash leaves the folded output on the checkpoint, and the supported retry path serializes and reloads that state. The fold's ownership lived in process memory, so the reloaded retry could not tell the crashed turn's own outputs from carried history: a handoff filter that removed the pair found the batch settling it anyway, the library's own ``remove_all_tools`` included. The commit boundary now records the call ids it folds on the held record itself (``folded_tool_outputs``, gated and validated with the other held keys under the unreleased 1.18 schema), together with the turn that owns them, and the in-memory set is gone. A settle or fold on an exit where a ``Handoff.input_filter`` actually ran (``SingleStepResult`` now says so) drops the batch's copies of the owned outputs: kept ones arrive through the resolved view in the same save, removed ones must not land, and the pairing prune takes their calls with them. Exits without a filter are untouched, so a partial approval's second resume still settles the earlier approval's pair from the batch, and the marker expires with its turn, so carried prior-turn history still settles at the reattaching entry. Pinned by the serialized-retry regression in both runners alongside the existing filter-contract pair, the partial-approval flow, and the carried-pair boundary; the marker's validator shape has its own rejection case; each guard proven red by mutation. --- src/agents/run.py | 13 ++- .../run_internal/agent_runner_helpers.py | 3 - src/agents/run_internal/run_loop.py | 23 ++-- src/agents/run_internal/run_steps.py | 5 + .../run_internal/session_persistence.py | 100 +++++++++++++----- src/agents/run_internal/turn_resolution.py | 10 +- src/agents/run_state.py | 55 +++++++--- tests/test_agent_runner_streamed.py | 4 + ...test_deferred_interrupted_session_write.py | 74 ++++++++++++- tests/test_run_impl_resume_paths.py | 7 ++ 10 files changed, 229 insertions(+), 65 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 3b86e117a1..1ff44d39cc 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1188,6 +1188,7 @@ def _mark_response_hooks_started() -> None: run_state, run_items=turn_session_items, run_items_are_the_session_view=True, + handoff_input_filtered=(turn_result.handoff_input_filtered), reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), @@ -1216,7 +1217,10 @@ def _mark_response_hooks_started() -> None: run_state=run_state, session=session, items=turn_session_items, - held_input=take_held_session_write(run_state), + claim_held=True, + handoff_input_filtered=( + turn_result.handoff_input_filtered + ), persisted_count=( run_state._current_turn_persisted_item_count ), @@ -1237,6 +1241,9 @@ def _mark_response_hooks_started() -> None: await settle_held_batch_for_emptied_turn( run_state, session, + handoff_input_filtered=( + turn_result.handoff_input_filtered + ), persisted_count=( run_state._current_turn_persisted_item_count ), @@ -1677,9 +1684,6 @@ async def _save_max_turns_handler_output( not resuming_turn or isinstance(run_state._current_step, NextStepRunAgain) ): run_state._current_turn_persisted_item_count = 0 - # A handoff filter's session authority covers one turn, so the - # folded-output record resets with the turn it described. - run_state._held_output_call_ids_folded_this_turn.clear() logger.debug("Running agent %s (turn %s)", current_agent.name, current_turn) @@ -2210,6 +2214,7 @@ async def _save_max_turns_handler_output( run_state, run_items=session_items_for_turn(turn_result), run_items_are_the_session_view=True, + handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=(run_state._reasoning_item_id_policy), ) append_model_response_if_new( diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index 9a1ccc6aef..92683557b8 100644 --- a/src/agents/run_internal/agent_runner_helpers.py +++ b/src/agents/run_internal/agent_runner_helpers.py @@ -646,9 +646,6 @@ async def save_final_turn_items_after_guardrails( items, reasoning_item_id_policy, pending_call_ids=_pending_approval_call_ids(run_state), - folded_output_call_ids=( - run_state._held_output_call_ids_folded_this_turn if run_state is not None else None - ), ) return await save_result_to_session( session, diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index e343585930..d2450c7ca4 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -393,6 +393,7 @@ async def _save_resumed_stream_items( items: list[RunItem], response_id: str | None, store: bool | None = None, + handoff_input_filtered: bool = False, ) -> None: if not await _should_persist_stream_items( session=session, @@ -411,7 +412,8 @@ async def _save_resumed_stream_items( 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, + claim_held=bool(items), + handoff_input_filtered=handoff_input_filtered, persisted_count=streamed_result._current_turn_persisted_item_count, response_id=response_id, reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, @@ -1159,7 +1161,10 @@ def _mark_response_hooks_started() -> None: streamed_result._original_input_for_persistence = session_items_snapshot async def _save_resumed_items( - items: list[RunItem], response_id: str | None, store_setting: bool | None + items: list[RunItem], + response_id: str | None, + store_setting: bool | None, + handoff_input_filtered: bool = False, ) -> None: await _save_resumed_stream_items( session=session, @@ -1167,6 +1172,7 @@ async def _save_resumed_items( streamed_result=streamed_result, run_state=run_state, items=items, + handoff_input_filtered=handoff_input_filtered, response_id=response_id, store=store_setting, ) @@ -1443,6 +1449,7 @@ async def _save_max_turns_items( run_state, run_items=turn_session_items, run_items_are_the_session_view=True, + handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1473,6 +1480,7 @@ async def _save_max_turns_items( await settle_held_batch_for_emptied_turn( run_state, session, + handoff_input_filtered=turn_result.handoff_input_filtered, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1519,6 +1527,7 @@ async def _save_max_turns_items( run_state, run_items=turn_session_items, run_items_are_the_session_view=True, + handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1531,6 +1540,7 @@ async def _save_max_turns_items( await settle_held_batch_for_emptied_turn( run_state, session, + handoff_input_filtered=turn_result.handoff_input_filtered, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1546,6 +1556,7 @@ async def _save_max_turns_items( list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, store_setting, + handoff_input_filtered=turn_result.handoff_input_filtered, ) if current_span is not None: current_span.finish(reset_current=True) @@ -1604,6 +1615,7 @@ async def _save_max_turns_items( run_state, run_items=turn_session_items, run_items_are_the_session_view=True, + handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1616,6 +1628,7 @@ async def _save_max_turns_items( await settle_held_batch_for_emptied_turn( run_state, session, + handoff_input_filtered=turn_result.handoff_input_filtered, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1631,6 +1644,7 @@ async def _save_max_turns_items( list(turn_session_items) if turn_session_items else [], turn_result.model_response.response_id, store_setting, + handoff_input_filtered=turn_result.handoff_input_filtered, ) run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( @@ -1724,9 +1738,6 @@ async def _save_max_turns_items( streamed_result._current_turn_persisted_item_count = 0 if run_state is not None: run_state._current_turn_persisted_item_count = 0 - # A handoff filter's session authority covers one turn, so the - # folded-output record resets with the turn it described. - run_state._held_output_call_ids_folded_this_turn.clear() if max_turns is not None and current_turn > max_turns: _error_tracing.attach_error_to_span( @@ -2042,7 +2053,6 @@ def _record_max_turns_handler_output( streamed_result._current_turn_persisted_item_count = 0 if run_state is not None: run_state._current_turn_persisted_item_count = 0 - run_state._held_output_call_ids_folded_this_turn.clear() if server_conversation_tracker is not None: server_conversation_tracker.track_server_items(turn_result.model_response) @@ -2125,6 +2135,7 @@ def _record_max_turns_handler_output( run_state, run_items=turn_session_items, run_items_are_the_session_view=True, + handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), ) elif parked_items_deferred and await _should_persist_stream_items( diff --git a/src/agents/run_internal/run_steps.py b/src/agents/run_internal/run_steps.py index 89e0489b50..ad7b13bcb5 100644 --- a/src/agents/run_internal/run_steps.py +++ b/src/agents/run_internal/run_steps.py @@ -215,6 +215,11 @@ class SingleStepResult: """Full unfiltered items for session history. When set, these are used instead of new_step_items for session saving and generated_items property.""" + handoff_input_filtered: bool = False + """Whether a ``Handoff.input_filter`` ran while resolving this step. A filter's + authority over session history covers the turn it filtered, so held settlement + consults this to know whether an absent output was removed on purpose.""" + nested_history_owned_items: list[NestedHistoryOwnedItem] | None = None """Items moved verbatim into SDK-default nested history for this handoff. diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 82a221e375..9a36c6e6b8 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -893,6 +893,8 @@ async def save_resumed_turn_items( wrapper: RunContextWrapper[Any] | None = None, run_state: RunState | None = None, held_input: Sequence[TResponseInputItem] | None = None, + claim_held: bool = False, + handoff_input_filtered: bool = False, ) -> int: """Persist resumed turn items and return the updated persisted count. @@ -903,6 +905,36 @@ async def save_resumed_turn_items( 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 claim_held and run_state is not None: + # The claim reads the fold marker before freeing the slot: the filter's + # removals are only visible against the resolved view, and the record is the + # marker's one durable home. + pending_record = run_state._pending_session_write + held_marker = ( + pending_record.get("folded_tool_outputs") if pending_record is not None else None + ) + held_input = take_held_session_write(run_state) + if ( + handoff_input_filtered + and held_input + and held_marker is not None + and held_marker["turn"] == run_state._current_turn + ): + # A handoff filter ran on this exit and these outputs belong to the turn + # it filtered: the batch's copy is not pairing evidence, so it drops + # unconditionally. A kept output arrives through the resolved view in this + # very save; a removed one must not land, and the pairing prune takes its + # call with it. The turn comparison is a cheap belt for a marker that + # outlived its turn, which the defer merge's rewrite excludes today. + gated = set(held_marker["call_ids"]) + held_input = [ + item + for item in held_input + if not ( + item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES + and item.get("call_id") in gated + ) + ] 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 @@ -922,9 +954,6 @@ async def save_resumed_turn_items( items, reasoning_item_id_policy, pending_call_ids=_pending_approval_call_ids(run_state), - folded_output_call_ids=( - run_state._held_output_call_ids_folded_this_turn if run_state is not None else None - ), ) saved_count = await save_result_to_session( session, @@ -967,6 +996,7 @@ async def settle_held_batch_for_emptied_turn( reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, + handoff_input_filtered: bool = False, ) -> int: """Settle what an emptied resolved turn's session view left in the held batch. @@ -981,7 +1011,8 @@ async def settle_held_batch_for_emptied_turn( run_state=run_state, session=session, items=[], - held_input=take_held_session_write(run_state), + claim_held=True, + handoff_input_filtered=handoff_input_filtered, persisted_count=persisted_count, response_id=response_id, reasoning_item_id_policy=reasoning_item_id_policy, @@ -1031,20 +1062,19 @@ def _held_items_safe_to_settle( reasoning_item_id_policy: ReasoningItemIdPolicy | None, *, pending_call_ids: set[str] | None = None, - folded_output_call_ids: set[str] | None = 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, and pairing evidence must come from the resolved - session view, not from the batch itself: ``HandoffInputData.new_items`` is the - session-history axis by contract, so an output the commit boundary folded into the - batch this run (``folded_output_call_ids``) settles only when the view kept it. A - dropped output takes its call with it, and a kept output keeps its call. Outputs - folded by an earlier process are carried prior-turn history and are not the - filter's to remove, exactly as the eager path cannot unpersist earlier turns. + Pairing is the safety predicate, and for outputs folded on the current turn the + pairing evidence comes from the resolved session view, not from the batch: the + claim (``take_held_session_write``) and the detached merge drop those copies + before this guard runs, so here a dropped output takes its call with it and a + kept output keeps its call. Outputs folded on earlier turns are carried history + and settle under the plain pairing rules, exactly as the eager path cannot + unpersist earlier turns. ``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 @@ -1052,21 +1082,6 @@ def _held_items_safe_to_settle( non-deferred park persists a call before its output exists. """ pending_call_ids = pending_call_ids or set() - if folded_output_call_ids: - # An output the commit boundary folded this turn belongs to the resolved - # session view: when the view kept it, it arrives through ``run_items`` in - # this very save, and when the filter removed it, it must not settle from the - # batch. Either way the batch's copy is not pairing evidence, so it drops - # unconditionally and the view decides what lands. - held_items = [ - item - for item in held_items - if not ( - isinstance(item, dict) - and item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES - and item.get("call_id") in folded_output_call_ids - ) - ] 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 @@ -1143,6 +1158,8 @@ def defer_interrupted_session_write( response_id: str | None = None, store: bool | None = None, run_items_are_the_session_view: bool = False, + handoff_input_filtered: bool = False, + folded_output_call_ids: Sequence[str] | None = None, ) -> None: """Register the interruption's withheld batch as a held pending Session write. @@ -1185,10 +1202,17 @@ 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 [] + standing_marker = pending.get("folded_tool_outputs") if pending is not None else None + gated_call_ids: set[str] = ( + set(standing_marker["call_ids"]) + if standing_marker is not None and standing_marker["turn"] == run_state._current_turn + else set() + ) if ( run_items_are_the_session_view + and handoff_input_filtered and pending is not None - and run_state._held_output_call_ids_folded_this_turn + and gated_call_ids ): # A detached exit folds through this merge, and the filter's view is exactly # ``run_items``: the batch's copy of an output this turn folded is never @@ -1202,7 +1226,7 @@ def defer_interrupted_session_write( for item in base_items if not ( item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES - and item.get("call_id") in run_state._held_output_call_ids_folded_this_turn + and item.get("call_id") in gated_call_ids ) ] items = deduplicate_input_items_preferring_latest(base_items + converted_run_items) @@ -1240,6 +1264,16 @@ def defer_interrupted_session_write( "store": pending["store"] if (pending is not None and "store" in pending) else store, "reasoning_item_id_policy": reasoning_item_id_policy, } + marker_call_ids = sorted(gated_call_ids | set(folded_output_call_ids or ())) + if marker_call_ids: + # Fold ownership rides the record with its turn, because a crashed resume can + # be serialized and retried: the retry's filter keeps its authority over the + # turn it is re-running, and the marker expires by itself once the turn moves + # on and the outputs become carried history. + record["folded_tool_outputs"] = { + "turn": run_state._current_turn, + "call_ids": marker_call_ids, + } run_state._pending_session_write = record @@ -1249,6 +1283,8 @@ def extend_held_session_write( run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, run_items_are_the_session_view: bool = False, + handoff_input_filtered: bool = False, + folded_output_call_ids: Sequence[str] | None = None, ) -> None: """Fold a detached exit's resolved items into the standing held batch. @@ -1269,6 +1305,8 @@ def extend_held_session_write( run_items=run_items, reasoning_item_id_policy=reasoning_item_id_policy, run_items_are_the_session_view=run_items_are_the_session_view, + handoff_input_filtered=handoff_input_filtered, + folded_output_call_ids=folded_output_call_ids, ) @@ -1280,6 +1318,10 @@ def take_held_session_write(run_state: RunState | None) -> list[TResponseInputIt ``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. + + A view-carrying settle that follows a handoff ``input_filter`` passes the record's + fold marker to ``save_resumed_turn_items`` before claiming, because the filter's + removals are only visible against the resolved view; the claim itself never drops. """ if run_state is None: return [] diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 43fbe0e204..d4242e3dab 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -755,6 +755,7 @@ def nest_history( tool_output_guardrail_results=list(tool_output_guardrail_results or []), session_step_items=session_step_items, nested_history_owned_items=nested_history_owned_items, + handoff_input_filtered=input_filter is not None, ) @@ -2551,16 +2552,13 @@ def _commit_tool_output(item: RunItem) -> None: # produces no new session items, and the batch would otherwise settle, or # be discarded as an emptied turn, without the output the tool produced. folded_call_id = extract_tool_call_id(getattr(item, "raw_item", None)) - if folded_call_id: - # Only this run can tell a current-turn folded output from carried - # prior-turn history, and a handoff filter's session authority covers - # exactly the current turn. The committer only ever folds the function - # family (measured), so the raw call id is the right key. - run_state._held_output_call_ids_folded_this_turn.add(folded_call_id) extend_held_session_write( run_state, run_items=[item], reasoning_item_id_policy=run_state._reasoning_item_id_policy, + # The committer only ever folds the function family (measured), so the + # raw call id is the right ownership key for the record's marker. + folded_output_call_ids=[folded_call_id] if folded_call_id else None, ) _register_tool_call_items(context_wrapper, [item]) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index ff66f455f1..5ba061effc 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -169,6 +169,21 @@ ] +class _FoldedToolOutputs(TypedDict): + """Outputs the commit boundary folded into the held batch, with the turn they belong to. + + A handoff filter's authority over session history covers one turn, and a crashed + resume can be serialized and retried, so ownership must ride the record rather + than live process state: while ``turn`` is still the current turn, the batch's + copies of these outputs are not pairing evidence and the resolved session view + decides what lands. Once the turn advances the marker expires and the outputs are + carried prior-turn history, which a later turn's filter is not entitled to remove. + """ + + turn: int + call_ids: list[str] + + class _PendingSessionWrite(TypedDict): """One canonical resumed-output append awaiting acknowledgement. @@ -188,6 +203,10 @@ class _PendingSessionWrite(TypedDict): 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. + + ``folded_tool_outputs`` records which of the batch's outputs the commit boundary + folded and on which turn, so the filter contract survives a serialized retry of + the crashed turn; see ``_FoldedToolOutputs``. """ session_id: str @@ -198,6 +217,7 @@ class _PendingSessionWrite(TypedDict): response_id: NotRequired[str | None] store: NotRequired[bool | None] reasoning_item_id_policy: NotRequired[ReasoningItemIdPolicy | None] + folded_tool_outputs: NotRequired[_FoldedToolOutputs] def _default_run_state_validation_error( @@ -258,8 +278,9 @@ def _default_run_state_validation_error( "Binds restored local MCP calls to their configured server and original tool name, " "preserves independent apply_patch approval scopes, binds function-tool approval " "decisions to their owning agent, and persists the interrupted turn's withheld " - "Session write, including the response it belongs to and the conversion policy its " - "items were registered under, so an approval resume can settle it under the " + "Session write, including the response it belongs to, the conversion policy its " + "items were registered under, and the fold ownership of its outputs, so an " + "approval resume can settle it under the " "output-guardrail gate." ), } @@ -911,14 +932,6 @@ class RunState(Generic[TContext, TAgent]): _session_write_in_progress: bool = field(default=False, repr=False) """Live ownership guard; independent serialized copies require caller serialization.""" - _held_output_call_ids_folded_this_turn: set[str] = field(default_factory=set, repr=False) - """Call ids of tool outputs the commit boundary folded into the held batch this - turn. Deliberately transient and reset at each turn boundary: a handoff filter's - authority over session history covers exactly one turn, and only the live run can - tell a folded output of the current turn from carried prior-turn history. After a - checkpoint the set is empty, so a reattaching entry settle keeps the carried batch - whole.""" - _terminal_unrecoverable: bool = field(default=False, repr=False) """Set once a final output, its guardrails, and its terminal hooks have all completed. @@ -968,7 +981,6 @@ def __init__( self._schema_version = CURRENT_SCHEMA_VERSION self._pending_session_write = None self._session_write_in_progress = False - self._held_output_call_ids_folded_this_turn = set() self._terminal_unrecoverable = False from .agent_tool_state import get_agent_tool_state_scope @@ -979,9 +991,6 @@ def _copy_for_result_checkpoint(self) -> RunState[TContext, TAgent]: copied = copy.copy(self) copied._pending_session_write = copy.deepcopy(self._pending_session_write) copied._session_write_in_progress = False - copied._held_output_call_ids_folded_this_turn = set( - self._held_output_call_ids_folded_this_turn - ) if self._context is None: return copied copied._context = self._context._copy_for_run_state() @@ -4549,7 +4558,7 @@ async def _build_run_state_from_json( ) base_keys = {"session_id", "items", "before", "persisted_count"} held_keys = ( - {"held", "response_id", "store", "reasoning_item_id_policy"} + {"held", "response_id", "store", "reasoning_item_id_policy", "folded_tool_outputs"} if held_keys_allowed else set() ) @@ -4573,6 +4582,21 @@ async def _build_run_state_from_json( "reasoning_item_id_policy" in pending_write and pending_write["reasoning_item_id_policy"] not in (None, "preserve", "omit") ) + or ( + "folded_tool_outputs" in pending_write + and ( + not isinstance(pending_write["folded_tool_outputs"], dict) + or set(pending_write["folded_tool_outputs"]) != {"turn", "call_ids"} + or type(pending_write["folded_tool_outputs"]["turn"]) is not int + or pending_write["folded_tool_outputs"]["turn"] < 0 + or not isinstance(pending_write["folded_tool_outputs"]["call_ids"], list) + or not pending_write["folded_tool_outputs"]["call_ids"] + or not all( + isinstance(call_id, str) + for call_id in pending_write["folded_tool_outputs"]["call_ids"] + ) + ) + ) # 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. @@ -4582,6 +4606,7 @@ async def _build_run_state_from_json( "response_id" in pending_write or "store" in pending_write or "reasoning_item_id_policy" in pending_write + or "folded_tool_outputs" in pending_write ) ) or not isinstance(pending_write.get("session_id"), str) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index d130da398e..da4117ef4e 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4315,6 +4315,8 @@ async def save_wrapper( wrapper: RunContextWrapper[Any] | None = None, run_state: RunState | None = None, held_input: Any = None, + claim_held: bool = False, + handoff_input_filtered: bool = False, ) -> int: observed_counts.append(persisted_count) result = await real_save_resumed( @@ -4327,6 +4329,8 @@ async def save_wrapper( wrapper=wrapper, run_state=run_state, held_input=held_input, + claim_held=claim_held, + handoff_input_filtered=handoff_input_filtered, ) return int(result) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index 389d792b03..c3b890e95f 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1511,7 +1511,6 @@ class _Session: } state._current_turn_persisted_item_count = 0 state._reasoning_item_id_policy = None - state._held_output_call_ids_folded_this_turn = set() defer_interrupted_session_write( state, @@ -1708,6 +1707,78 @@ async def test_a_carried_pair_survives_a_filtered_handoff_on_a_later_run( assert _parked_pair(items) == _EXPECTED_PAIR +def _boom_extractor(ctx: Any) -> dict[str, Any]: + raise RuntimeError("extractor boom") + + +@function_tool(needs_approval=True, custom_data_extractor=_boom_extractor) +async def read_secret_with_failing_extractor(query: str) -> str: + return "SECRET-VALUE-42" + + +def _make_secret_failing_extractor_handoff_agent() -> Agent: + """A secret-bearing gated tool whose extractor crashes, resolved into a filtered handoff.""" + from agents import handoff + from agents.extensions.handoff_filters import remove_all_tools + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + return Agent( + name="deferred repro (secret, failing extractor)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call( + "read_secret_with_failing_extractor", + {"query": "x"}, + call_id="call_SECRET", + ), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[read_secret_with_failing_extractor], + handoffs=[handoff(target, input_filter=remove_all_tools)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_filters_authority_survives_a_json_retry_of_the_crashed_turn( + streamed: bool, +) -> None: + # A post-output callback crash leaves the folded output on the checkpoint, and the + # supported retry path serializes and reloads that state. The fold's ownership + # rides the record with the turn it belongs to, so the reloaded retry's filter + # keeps its authority over the turn it is re-running: the batch's copy is not + # pairing evidence and the filtered secret stays out of the Session. + session = SimpleListSession() + agent = _make_secret_failing_extractor_handoff_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + with pytest.raises(Exception, match="extractor boom"): + await _run(agent, state, session, streamed=streamed) + + reloaded = await RunState.from_json(agent, json.loads(json.dumps(state.to_json()))) + retry = await _run(agent, reloaded, session, streamed=streamed) + assert retry.final_output == "done" + + items = await session.get_items() + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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))}" + + class _CompactionRecordingSession(SimpleListSession): """Record the compaction bookkeeping a compaction-aware backend expects.""" @@ -1891,7 +1962,6 @@ async def test_the_final_sweep_settle_defers_compaction_and_counts_what_it_wrote state._pending_session_write = None state._current_turn_persisted_item_count = 0 state._reasoning_item_id_policy = None - state._held_output_call_ids_folded_this_turn = set() state._current_step = None held: list[TResponseInputItem] = [ {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index 8edae3d19f..b23be21bc7 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -509,6 +509,7 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( "held-under-1-17", "held-keys-without-held", "policy-shape", + "fold-marker-shape", ], ) async def test_pending_session_write_rejects_invalid_serialized_checkpoint(invalid: str) -> None: @@ -535,6 +536,12 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval payload["pending_session_write"]["held"] = True payload["pending_session_write"]["before"] = None payload["pending_session_write"]["reasoning_item_id_policy"] = "banana" + elif invalid == "fold-marker-shape": + # The fold marker names outputs and the turn that owns them; a malformed + # marker would silently change which outputs the filter contract gates. + payload["pending_session_write"]["held"] = True + payload["pending_session_write"]["before"] = None + payload["pending_session_write"]["folded_tool_outputs"] = {"turn": -1, "call_ids": []} 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 80962b5a66f24fb99b636cdfef52fb1c0b21b64d Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Wed, 9 Sep 2026 11:34:47 +0200 Subject: [PATCH 33/38] fix(sessions): the filter's authority covers the batch's unkeyed companions too The held batch carries the parked response's unkeyed companions (an assistant preamble, an id-less reasoning item), and a handoff filter that removed them from the session view found them persisted from the batch anyway: the gate covered the owned outputs and nothing else. On a filtered current-turn claim or detached fold, an unkeyed batch item now survives only if the resolved view kept it, matched by the same fingerprint the dedup uses. Calls and request kinds stay with the pairing rule, owned outputs keep their unconditional drop, and carried prior-turn history remains outside the filter's reach. Pinned by the filtered-preamble regression in both runners, each branch proven red by mutation. --- src/agents/run.py | 4 + src/agents/run_internal/run_loop.py | 13 +++ .../run_internal/session_persistence.py | 107 ++++++++++++----- tests/test_agent_runner_streamed.py | 2 + ...test_deferred_interrupted_session_write.py | 109 ++++++++++++++++++ 5 files changed, 209 insertions(+), 26 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 1ff44d39cc..13a9dd69ba 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1189,6 +1189,7 @@ def _mark_response_hooks_started() -> None: run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=(turn_result.handoff_input_filtered), + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=( run_state._reasoning_item_id_policy ), @@ -1221,6 +1222,7 @@ def _mark_response_hooks_started() -> None: handoff_input_filtered=( turn_result.handoff_input_filtered ), + filtered_context_items=turn_result.pre_step_items, persisted_count=( run_state._current_turn_persisted_item_count ), @@ -1244,6 +1246,7 @@ def _mark_response_hooks_started() -> None: handoff_input_filtered=( turn_result.handoff_input_filtered ), + filtered_context_items=turn_result.pre_step_items, persisted_count=( run_state._current_turn_persisted_item_count ), @@ -2215,6 +2218,7 @@ async def _save_max_turns_handler_output( run_items=session_items_for_turn(turn_result), run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=(run_state._reasoning_item_id_policy), ) append_model_response_if_new( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index d2450c7ca4..cbd58360dd 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -394,6 +394,7 @@ async def _save_resumed_stream_items( response_id: str | None, store: bool | None = None, handoff_input_filtered: bool = False, + filtered_context_items: list[RunItem] | None = None, ) -> None: if not await _should_persist_stream_items( session=session, @@ -414,6 +415,7 @@ async def _save_resumed_stream_items( # re-park) or is discarded explicitly at the exit that owns that decision. claim_held=bool(items), handoff_input_filtered=handoff_input_filtered, + filtered_context_items=filtered_context_items, persisted_count=streamed_result._current_turn_persisted_item_count, response_id=response_id, reasoning_item_id_policy=streamed_result._reasoning_item_id_policy, @@ -1165,6 +1167,7 @@ async def _save_resumed_items( response_id: str | None, store_setting: bool | None, handoff_input_filtered: bool = False, + filtered_context_items: list[RunItem] | None = None, ) -> None: await _save_resumed_stream_items( session=session, @@ -1173,6 +1176,7 @@ async def _save_resumed_items( run_state=run_state, items=items, handoff_input_filtered=handoff_input_filtered, + filtered_context_items=filtered_context_items, response_id=response_id, store=store_setting, ) @@ -1450,6 +1454,7 @@ async def _save_max_turns_items( run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1481,6 +1486,7 @@ async def _save_max_turns_items( run_state, session, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1528,6 +1534,7 @@ async def _save_max_turns_items( run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1541,6 +1548,7 @@ async def _save_max_turns_items( run_state, session, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1557,6 +1565,7 @@ async def _save_max_turns_items( turn_result.model_response.response_id, store_setting, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=list(turn_result.pre_step_items), ) if current_span is not None: current_span.finish(reset_current=True) @@ -1616,6 +1625,7 @@ async def _save_max_turns_items( run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=( streamed_result._reasoning_item_id_policy ), @@ -1629,6 +1639,7 @@ async def _save_max_turns_items( run_state, session, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, persisted_count=( streamed_result._current_turn_persisted_item_count ), @@ -1645,6 +1656,7 @@ async def _save_max_turns_items( turn_result.model_response.response_id, store_setting, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=list(turn_result.pre_step_items), ) run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( @@ -2136,6 +2148,7 @@ def _record_max_turns_handler_output( run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), ) elif parked_items_deferred and await _should_persist_stream_items( diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 9a36c6e6b8..b70d488ce6 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -895,6 +895,7 @@ async def save_resumed_turn_items( held_input: Sequence[TResponseInputItem] | None = None, claim_held: bool = False, handoff_input_filtered: bool = False, + filtered_context_items: Sequence[RunItem] | None = None, ) -> int: """Persist resumed turn items and return the updated persisted count. @@ -920,21 +921,46 @@ async def save_resumed_turn_items( and held_marker is not None and held_marker["turn"] == run_state._current_turn ): - # A handoff filter ran on this exit and these outputs belong to the turn - # it filtered: the batch's copy is not pairing evidence, so it drops - # unconditionally. A kept output arrives through the resolved view in this - # very save; a removed one must not land, and the pairing prune takes its - # call with it. The turn comparison is a cheap belt for a marker that - # outlived its turn, which the defer merge's rewrite excludes today. + # A handoff filter ran on this exit and the batch belongs to the turn it + # filtered: the batch's copies are not pairing evidence, so the owned + # outputs drop unconditionally (a kept one arrives through the resolved + # view in this very save, a removed one must not land, and the pairing + # prune takes its call with it), and the response's unkeyed companions (an + # assistant preamble, an id-less reasoning item) survive only if the view + # kept them, matched by the same fingerprint the dedup uses. Calls and + # request kinds stay with the pairing rule, and carried outputs from + # earlier turns are not the filter's to remove. The turn comparison is a + # cheap belt for a marker that outlived its turn, which the defer merge's + # rewrite excludes today. gated = set(held_marker["call_ids"]) - held_input = [ - item - for item in held_input - if not ( - item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES - and item.get("call_id") in gated - ) - ] + ignore_ids = _ignore_ids_for_matching(session) if session is not None else False + view_fingerprints = set() + # The companions of the parked response ride the pre-step view, so the + # filter's verdict on them is only visible there: absent from the whole + # filtered view means removed on purpose, while absent from the resolved + # items alone says nothing (an additive filter keeps them in pre-step). + for run_item in [*items, *(filtered_context_items or [])]: + converted_view = run_item_to_input_item(run_item, reasoning_item_id_policy) + if converted_view is not None: + view_fingerprints.add( + _fingerprint_or_repr(converted_view, ignore_ids_for_matching=ignore_ids) + ) + kept_held: list[TResponseInputItem] = [] + for item in held_input: + item_type = item.get("type") + if item_type in _LOCAL_CONTINUATION_OUTPUT_TYPES: + if item.get("call_id") not in gated: + kept_held.append(item) + continue + if _held_pair_identity(item) is not None or "call_id" in item: + kept_held.append(item) + continue + if ( + _fingerprint_or_repr(item, ignore_ids_for_matching=ignore_ids) + in view_fingerprints + ): + kept_held.append(item) + held_input = kept_held 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 @@ -997,6 +1023,7 @@ async def settle_held_batch_for_emptied_turn( store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, handoff_input_filtered: bool = False, + filtered_context_items: Sequence[RunItem] | None = None, ) -> int: """Settle what an emptied resolved turn's session view left in the held batch. @@ -1013,6 +1040,7 @@ async def settle_held_batch_for_emptied_turn( items=[], claim_held=True, handoff_input_filtered=handoff_input_filtered, + filtered_context_items=filtered_context_items, persisted_count=persisted_count, response_id=response_id, reasoning_item_id_policy=reasoning_item_id_policy, @@ -1160,6 +1188,7 @@ def defer_interrupted_session_write( run_items_are_the_session_view: bool = False, handoff_input_filtered: bool = False, folded_output_call_ids: Sequence[str] | None = None, + filtered_context_items: Sequence[RunItem] | None = None, ) -> None: """Register the interruption's withheld batch as a held pending Session write. @@ -1217,18 +1246,42 @@ def defer_interrupted_session_write( # A detached exit folds through this merge, and the filter's view is exactly # ``run_items``: the batch's copy of an output this turn folded is never # pairing evidence, because a kept output rides back in through the view in - # this same merge and a removed one must not reach the reattach. An unpaired - # call this leaves behind is the entry settle's to prune, with the full - # batch-plus-view pairing in hand. Parks and re-parks are unaffected: their - # view always carries their own outputs (measured, nested history included). - base_items = [ - item - for item in base_items - if not ( - item.get("type") in _LOCAL_CONTINUATION_OUTPUT_TYPES - and item.get("call_id") in gated_call_ids - ) - ] + # this same merge and a removed one must not reach the reattach, and the + # response's unkeyed companions survive only if the view kept them, matched by + # the dedup's own fingerprint. An unpaired call this leaves behind is the + # entry settle's to prune, with the full batch-plus-view pairing in hand. + # Parks and re-parks are unaffected: their view always carries their own + # outputs (measured, nested history included). + merge_ignore_ids = _ignore_ids_for_matching(session) if session is not None else False + merge_context_fingerprints: set[str] = set() + for context_item in filtered_context_items or (): + converted_context = run_item_to_input_item(context_item, reasoning_item_id_policy) + if converted_context is not None: + merge_context_fingerprints.add( + _fingerprint_or_repr( + converted_context, ignore_ids_for_matching=merge_ignore_ids + ) + ) + merge_view_fingerprints = { + _fingerprint_or_repr(item, ignore_ids_for_matching=merge_ignore_ids) + for item in converted_run_items + } | merge_context_fingerprints + kept_base: list[TResponseInputItem] = [] + for item in base_items: + item_type = item.get("type") + if item_type in _LOCAL_CONTINUATION_OUTPUT_TYPES: + if item.get("call_id") not in gated_call_ids: + kept_base.append(item) + continue + if _held_pair_identity(item) is not None or "call_id" in item: + kept_base.append(item) + continue + if ( + _fingerprint_or_repr(item, ignore_ids_for_matching=merge_ignore_ids) + in merge_view_fingerprints + ): + kept_base.append(item) + base_items = kept_base 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] @@ -1285,6 +1338,7 @@ def extend_held_session_write( run_items_are_the_session_view: bool = False, handoff_input_filtered: bool = False, folded_output_call_ids: Sequence[str] | None = None, + filtered_context_items: Sequence[RunItem] | None = None, ) -> None: """Fold a detached exit's resolved items into the standing held batch. @@ -1307,6 +1361,7 @@ def extend_held_session_write( run_items_are_the_session_view=run_items_are_the_session_view, handoff_input_filtered=handoff_input_filtered, folded_output_call_ids=folded_output_call_ids, + filtered_context_items=filtered_context_items, ) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index da4117ef4e..8fce250e85 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4317,6 +4317,7 @@ async def save_wrapper( held_input: Any = None, claim_held: bool = False, handoff_input_filtered: bool = False, + filtered_context_items: Any = None, ) -> int: observed_counts.append(persisted_count) result = await real_save_resumed( @@ -4331,6 +4332,7 @@ async def save_wrapper( held_input=held_input, claim_held=claim_held, handoff_input_filtered=handoff_input_filtered, + filtered_context_items=filtered_context_items, ) return int(result) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index c3b890e95f..272be4ff6b 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -1751,6 +1751,115 @@ def _make_secret_failing_extractor_handoff_agent() -> Agent: ) +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_filtered_unkeyed_sibling_stays_out_of_the_session(streamed: bool) -> None: + # The batch carries the parked response's unkeyed companions (an assistant + # preamble, an id-less reasoning item), and the filter's authority covers them + # exactly as it covers the outputs: removed from the view means removed from + # session history, keyed or not. + from agents import HandoffInputData, handoff + + def drops_preamble(data: HandoffInputData) -> HandoffInputData: + def keep(items: tuple) -> tuple: + return tuple(item for item in items if item.type != "message_output_item") + + 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")])]), + ) + agent = Agent( + name="deferred repro (unkeyed sibling)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + assistant_message("PREAMBLE-THE-FILTER-REMOVED"), + 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=drops_preamble)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert not any("PREAMBLE-THE-FILTER-REMOVED" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_PARKED" in calls and "call_PARKED" in outputs + assert calls - outputs == set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_additive_filter_keeps_the_batchs_companions(streamed: bool) -> None: + # A filter that only appends removed nothing, so the parked response's unkeyed + # companions must persist: their absence from the resolved items says nothing, + # because they ride the filtered pre-step view, and only absence from the whole + # filtered view is the filter's verdict. + from agents import HandoffInputData, handoff + from agents.items import MessageOutputItem + + def additive(data: HandoffInputData) -> HandoffInputData: + injected = MessageOutputItem( + agent=Agent(name="filler", instructions="x"), + raw_item=assistant_message("INJECTED-BY-FILTER"), + ) + return data.clone(new_items=(*data.new_items, injected)) + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + agent = Agent( + name="deferred repro (additive filter)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + assistant_message("COMPANION-KEPT-BY-FILTER"), + 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=additive)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert any("COMPANION-KEPT-BY-FILTER" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_PARKED" in calls and "call_PARKED" in outputs + + @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) async def test_the_filters_authority_survives_a_json_retry_of_the_crashed_turn( From 46599c9f5c129b46ab6a0919418f35ef1984cc1e Mon Sep 17 00:00:00 2001 From: Julio de la Calle Date: Sat, 26 Sep 2026 08:15:35 +0200 Subject: [PATCH 34/38] fix(sessions): relabel checkpoints as 1.17 the way the released reader reads them Main bumped the schema to 1.18 for agent-scoped approvals and MCP recipient bindings while this branch was in review, so the held pending write now shares the unreleased 1.18 label instead of taking a number of its own, the same way 1.17 absorbed the pending Session write before it. The two tests that relabel a current checkpoint as 1.17 must therefore also strip the 1.18-only context and response fields, exactly as main's own relabeling tests do, or the reader rejects the payload before the pending-write validator ever runs. --- src/agents/run_state.py | 3 +-- tests/test_run_impl_resume_paths.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 5ba061effc..573e43f4be 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -280,8 +280,7 @@ def _default_run_state_validation_error( "decisions to their owning agent, and persists the interrupted turn's withheld " "Session write, including the response it belongs to, the conversion policy its " "items were registered under, and the fold ownership of its outputs, so an " - "approval resume can settle it under the " - "output-guardrail gate." + "approval resume can settle it under the output-guardrail gate." ), } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) diff --git a/tests/test_run_impl_resume_paths.py b/tests/test_run_impl_resume_paths.py index b23be21bc7..ea66ebf348 100644 --- a/tests/test_run_impl_resume_paths.py +++ b/tests/test_run_impl_resume_paths.py @@ -498,6 +498,16 @@ async def test_failed_streamed_result_checkpoint_retains_detached_pending_write( assert _charge_pair(await session.get_items()) == ["function_call", "function_call_output"] +def _relabel_as_older_schema(payload: dict[str, Any], version: str) -> None: + """Rewrite the 1.18-only agent-scoped approvals and MCP bindings the older readers refuse.""" + for entry in payload["context"].pop("function_tool_approvals", []): + payload["context"]["approvals"][entry["tool_key"]] = entry["decision"] + response = payload.get("last_processed_response") + if isinstance(response, dict): + response.pop("mcp_tool_bindings", None) + payload["$schemaVersion"] = version + + @pytest.mark.asyncio @pytest.mark.parametrize( "invalid", @@ -519,9 +529,7 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval await _run_session_resume(agent, state, session, False) payload = state.to_json() if invalid == "old-schema": - for entry in payload["context"].pop("function_tool_approvals", []): - payload["context"]["approvals"][entry["tool_key"]] = entry["decision"] - payload["$schemaVersion"] = "1.16" + _relabel_as_older_schema(payload, "1.16") elif invalid == "batch-shape": payload["pending_session_write"]["items"] = "not an item batch" elif invalid == "held-shape": @@ -545,7 +553,7 @@ async def test_pending_session_write_rejects_invalid_serialized_checkpoint(inval 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" + _relabel_as_older_schema(payload, "1.17") payload["pending_session_write"]["held"] = True payload["pending_session_write"]["before"] = None else: @@ -566,7 +574,7 @@ async def test_pending_session_write_without_the_held_key_keeps_its_meaning() -> await _run_session_resume(agent, state, session, False) payload = state.to_json() assert "held" not in payload["pending_session_write"] - payload["$schemaVersion"] = "1.17" + _relabel_as_older_schema(payload, "1.17") payload["pending_session_write"].pop("response_id", None) restored = await RunState.from_json(agent, payload) From d9f6d742464d63aed65a64ca92126d350bed891c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Sun, 27 Sep 2026 12:24:11 -0700 Subject: [PATCH 35/38] fix(sessions): retain accepted held history through finalization failures --- src/agents/run.py | 14 ++-- .../run_internal/agent_runner_helpers.py | 15 ++-- src/agents/run_internal/run_loop.py | 9 +-- .../run_internal/session_persistence.py | 46 +++++------- tests/test_agent_runner_streamed.py | 4 +- ...test_deferred_interrupted_session_write.py | 75 ++++++++++++------- 6 files changed, 87 insertions(+), 76 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index dcf0de9d61..a4801b4bf3 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -141,6 +141,7 @@ admit_pending_input, commit_server_pending_input, defer_interrupted_session_write, + discard_held_current_response, extend_held_session_write, persist_session_items_for_guardrail_trip, prepare_input_with_session, @@ -1390,12 +1391,9 @@ 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) + # Redaction owns only this response; accepted prior + # held turns still need to reach the Session. + discard_held_current_response(run_state) try: await save_final_turn_items_after_guardrails( session=session, @@ -1439,7 +1437,6 @@ def _mark_response_hooks_started() -> None: _attempt_input_guardrail_results() ), items=final_turn_items, - held_write=take_held_session_write(run_state), response_id=turn_result.model_response.response_id, store=store_setting, wrapper=context_wrapper, @@ -1467,11 +1464,12 @@ def _mark_response_hooks_started() -> None: # Safe even when the guardrail rebuild above already # recovered the parked response: the save deduplicates # the combined batch. - held_write=take_held_session_write(run_state), response_id=turn_result.model_response.response_id, store=store_setting, wrapper=context_wrapper, ) + if session is None: + take_held_session_write(run_state) # The append and any post-append maintenance both succeeded, # so the turn is durable and the state is open again. if run_state is not None: diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py index fc0ed4e430..1319acb343 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 @@ -48,6 +48,7 @@ held_session_items_for_save, save_result_to_session, save_resumed_turn_items, + take_held_session_write, ) from .tool_use_tracker import AgentToolUseTracker, serialize_tool_use_tracker from .turn_preparation import get_model @@ -603,18 +604,20 @@ 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_write: _PendingSessionWrite | None = None, ) -> int: """Persist deferred final-turn items without skipping a partially persisted resumed turn. - ``held_write`` 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. + Claim held history only after persistence is enabled and input guardrails permit + the write. Detached failures retain their checkpoint until the owning terminal + exit decides whether completion succeeded. """ - if not session_persistence_enabled or (not items and held_write is None): + if session is None or not session_persistence_enabled: return 0 if input_guardrails_triggered(input_guardrail_results): return 0 + held_write = take_held_session_write(run_state) + if not items and held_write is None: + return 0 # 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. diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index b166040573..b545d60923 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -178,6 +178,7 @@ admit_pending_input, commit_server_pending_input, defer_interrupted_session_write, + discard_held_current_response, extend_held_session_write, persist_session_items_for_guardrail_trip, prepare_compaction_model_input, @@ -581,11 +582,9 @@ 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) + # Only this response is blocked. Preserve accepted earlier held history + # for the same recoverable append as the sanitized current response. + discard_held_current_response(streamed_result._state) if retained_items: try: await save_items(retained_items, response_id, store_setting) diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 43b0f42e4b..67005b70fc 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -254,29 +254,6 @@ 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, @@ -290,12 +267,6 @@ 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 @@ -932,6 +903,8 @@ async def save_resumed_turn_items( 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: + return persisted_count if claim_held: held_write = take_held_session_write(run_state) settling_held = held_write is not None @@ -1323,6 +1296,21 @@ def extend_held_session_write( ) +def discard_held_current_response(run_state: RunState | None) -> None: + """Remove blocked current-response items while retaining accepted prior turns.""" + if run_state is None: + return + pending = run_state._pending_session_write + if pending is None or not pending.get("held"): + return + start = _held_current_response_start(pending, run_state._current_turn) + if start == 0: + run_state._pending_session_write = None + return + pending["items"] = pending["items"][:start] + pending["current_response"] = {"turn": run_state._current_turn, "start": start} + + def take_held_session_write(run_state: RunState | None) -> _PendingSessionWrite | None: """Claim the withheld record, retaining its response boundary until settlement.""" if run_state is None: diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 8fce250e85..cd31c8a6d8 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4314,7 +4314,7 @@ async def save_wrapper( store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, run_state: RunState | None = None, - held_input: Any = None, + held_write: Any = None, claim_held: bool = False, handoff_input_filtered: bool = False, filtered_context_items: Any = None, @@ -4329,7 +4329,7 @@ async def save_wrapper( store=store, wrapper=wrapper, run_state=run_state, - held_input=held_input, + held_write=held_write, claim_held=claim_held, handoff_input_filtered=handoff_input_filtered, filtered_context_items=filtered_context_items, diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py index adbc89a377..acfde34747 100644 --- a/tests/test_deferred_interrupted_session_write.py +++ b/tests/test_deferred_interrupted_session_write.py @@ -312,13 +312,6 @@ async def clear_session(self, *, wrapper: RunContextWrapper[Any] | None = None) await super().clear_session() -class _LegacyGetItemsSession(SimpleListSession): - """A pre-limit Session whose ``get_items`` takes no arguments at all.""" - - async def get_items(self) -> list[TResponseInputItem]: # type: ignore[override] - return await super().get_items() - - class _AppendRecordingSession(SimpleListSession): """Record each ``add_items`` batch to observe write ordering and granularity.""" @@ -657,23 +650,6 @@ async def test_settle_reaches_a_context_aware_session_through_the_wrapper( 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 @@ -1346,6 +1322,7 @@ async def test_zero_count_final_save_arms_recovery_even_when_deduplicated() -> N ] session.failure = "before" + state._pending_session_write = _held_write(held) with pytest.raises(RuntimeError, match="session append failed"): await save_final_turn_items_after_guardrails( session=session, @@ -1354,7 +1331,6 @@ async def test_zero_count_final_save_arms_recovery_even_when_deduplicated() -> N input_guardrail_results=[], items=final_items, response_id=None, - held_write=_held_write(held), ) # The append was registered before it ran, so the batch is recorded to reconcile. @@ -1864,6 +1840,53 @@ async def test_handoff_filter_applies_to_sibling_completed_before_approval(strea ) +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_detached_terminal_guardrail_failure_keeps_unsettled_checkpoint( + streamed: bool, +) -> None: + from agents.exceptions import UserError + + session = SimpleListSession() + agent = _make_terminal_tool_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + agent.output_guardrails = [always_crashes] + with pytest.raises(RuntimeError, match="guardrail crashed"): + await _run(agent, state, None, streamed=streamed) + + assert state._pending_session_write is not None + agent.output_guardrails = [always_fine] + # A terminal side effect without a completed save must fail closed on reload, + # rather than allowing a retry that forgets the executed tool. + with pytest.raises(UserError, match="pending Session write is invalid"): + await RunState.from_json(agent, state.to_json()) + assert _parked_pair(await session.get_items()) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_later_tripwire_preserves_accepted_detached_history(streamed: bool) -> None: + agent = _make_two_park_agent() + agent.tool_use_behavior = StopAtTools(stop_at_tool_names=["write_other"]) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + detached = await _run(agent, state, None, streamed=streamed) + state = await _serialized_round_trip(detached, agent) + state.approve(state.get_interruptions()[0]) + agent.output_guardrails = [always_trips] + + with pytest.raises(OutputGuardrailTripwireTriggered): + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert _call_ids(items) == ["call_A", "call_B"] + outputs = [item for item in items if item.get("type") == "function_call_output"] + assert [item.get("call_id") for item in outputs] == ["call_A", "call_B"] + assert outputs[0]["output"] == "wrote:a" + assert "other:b" not in json.dumps(items) + assert state._pending_session_write is None + + @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) async def test_detached_repark_terminal_settle_preserves_each_response_once( @@ -2172,6 +2195,7 @@ async def test_the_final_sweep_settle_defers_compaction_and_counts_what_it_wrote {"type": "function_call_output", "call_id": "call_PARKED", "output": "ok"}, ] + state._pending_session_write = _held_write(held) count = await save_final_turn_items_after_guardrails( session=session, run_state=state, @@ -2179,7 +2203,6 @@ async def test_the_final_sweep_settle_defers_compaction_and_counts_what_it_wrote input_guardrail_results=[], items=[MessageOutputItem(agent=agent, raw_item=assistant_message("done"))], response_id="resp_final", - held_write=_held_write(held), ) assert [entry for entry in session.compactions if "deferred" in entry] == [ From b811ef464d310b754d5a0d34a6cfb903f72e2575 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Sun, 27 Sep 2026 17:41:02 -0700 Subject: [PATCH 36/38] fix(sessions): centralize resumed persistence and preserve filtered history --- src/agents/run.py | 99 +- src/agents/run_internal/run_loop.py | 195 +- .../run_internal/session_persistence.py | 75 +- src/agents/run_internal/turn_resolution.py | 7 + tests/deferred_session_writes/__init__.py | 0 tests/deferred_session_writes/helpers.py | 316 +++ .../test_compaction.py | 329 +++ tests/deferred_session_writes/test_filters.py | 579 ++++ .../deferred_session_writes/test_max_turns.py | 227 ++ .../test_pending_write.py | 424 +++ .../deferred_session_writes/test_recovery.py | 369 +++ tests/deferred_session_writes/test_resume.py | 618 ++++ tests/test_agent_runner_streamed.py | 7 +- ...test_deferred_interrupted_session_write.py | 2499 ----------------- 14 files changed, 2994 insertions(+), 2750 deletions(-) create mode 100644 tests/deferred_session_writes/__init__.py create mode 100644 tests/deferred_session_writes/helpers.py create mode 100644 tests/deferred_session_writes/test_compaction.py create mode 100644 tests/deferred_session_writes/test_filters.py create mode 100644 tests/deferred_session_writes/test_max_turns.py create mode 100644 tests/deferred_session_writes/test_pending_write.py create mode 100644 tests/deferred_session_writes/test_recovery.py create mode 100644 tests/deferred_session_writes/test_resume.py delete mode 100644 tests/test_deferred_interrupted_session_write.py diff --git a/src/agents/run.py b/src/agents/run.py index a4801b4bf3..23b7f459b7 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -107,6 +107,7 @@ ) from .run_internal.items import ( copy_input_items, + ensure_nested_history_run_item_occurrence_key, normalize_resumed_input, reconcile_nested_history_owned_input_after_rewrite, ) @@ -143,15 +144,14 @@ defer_interrupted_session_write, discard_held_current_response, extend_held_session_write, + persist_resumed_turn, persist_session_items_for_guardrail_trip, prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, resume_pending_session_write, resumed_turn_items, 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, ) @@ -1105,6 +1105,8 @@ def _mark_response_hooks_started() -> None: run_state._last_processed_response, run_state, ) + for item in resumed_response_boundary.items: + ensure_nested_history_run_item_occurrence_key(item) blocked_output_owner_starts = _BlockedOutputOwnerStarts( nonstreamed_session_items=(resumed_response_boundary.session_start), run_state_generated_items=( @@ -1181,84 +1183,28 @@ def _mark_response_hooks_started() -> None: # below, which claims the held batch itself. and not isinstance(turn_result.next_step, NextStepFinalOutput) ): - 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, - run_items_are_the_session_view=True, - handoff_input_filtered=(turn_result.handoff_input_filtered), - filtered_context_items=turn_result.pre_step_items, - reasoning_item_id_policy=( - run_state._reasoning_item_id_policy - ), - ) - 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 + run_state._current_turn_persisted_item_count = ( + # Keep the count returned by the shared persistence operation. + await persist_resumed_turn( + run_state=run_state, + session=session if session_persistence_enabled else None, + items=turn_session_items, + defer_settlement=( + isinstance(turn_result.next_step, NextStepInterruption) + and _should_defer_interrupted_session_items( + current_agent, run_config + ) ), + handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, + current_response_items=resumed_response_boundary.items, + 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, ) - 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, - claim_held=True, - handoff_input_filtered=( - turn_result.handoff_input_filtered - ), - filtered_context_items=turn_result.pre_step_items, - 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 settles what the session - # view left the batch: outputs the filter removed - # this turn dropped with their calls at the fold - # boundary, and carried prior-turn pairs still land. - run_state._current_turn_persisted_item_count = ( - await settle_held_batch_for_emptied_turn( - run_state, - session, - handoff_input_filtered=( - turn_result.handoff_input_filtered - ), - filtered_context_items=turn_result.pre_step_items, - 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. @@ -2216,7 +2162,6 @@ async def _save_max_turns_handler_output( run_items=session_items_for_turn(turn_result), run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=(run_state._reasoning_item_id_policy), ) append_model_response_if_new( diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index b545d60923..3f625a938a 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -145,6 +145,7 @@ copy_input_items, deduplicate_input_items_preferring_latest, ensure_input_item_format, + ensure_nested_history_run_item_occurrence_key, normalize_resumed_input, prepare_model_input_items, reconcile_nested_history_owned_input_after_rewrite, @@ -180,6 +181,7 @@ defer_interrupted_session_write, discard_held_current_response, extend_held_session_write, + persist_resumed_turn, persist_session_items_for_guardrail_trip, prepare_compaction_model_input, prepare_input_with_session, @@ -191,7 +193,6 @@ 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, ) @@ -1183,6 +1184,35 @@ async def _save_resumed_items( store=store_setting, ) + async def _persist_resumed_items( + turn_result: SingleStepResult, *, defer_settlement: bool = False + ) -> None: + if session is not None and not await _should_persist_stream_items( + session=session, + server_conversation_tracker=server_conversation_tracker, + streamed_result=streamed_result, + ): + take_held_session_write(run_state) + return + streamed_result._current_turn_persisted_item_count = await persist_resumed_turn( + run_state=run_state, + session=session, + items=turn_session_items, + defer_settlement=defer_settlement, + handoff_input_filtered=turn_result.handoff_input_filtered, + filtered_context_items=turn_result.pre_step_items, + current_response_items=resumed_response_boundary.items, + 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, + ) + if run_state is not None: + run_state._current_turn_persisted_item_count = ( + streamed_result._current_turn_persisted_item_count + ) + async def _save_stream_items_with_count( items: list[RunItem], response_id: str | None, store_setting: bool | None ) -> None: @@ -1346,6 +1376,8 @@ async def _save_max_turns_items( run_state._last_processed_response, run_state, ) + for item in resumed_response_boundary.items: + ensure_nested_history_run_item_occurrence_key(item) blocked_output_owner_starts = _BlockedOutputOwnerStarts( run_state_generated_items=resumed_response_boundary.generated_start, run_state_session_items=resumed_response_boundary.session_start, @@ -1449,70 +1481,14 @@ 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, - run_items_are_the_session_view=True, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_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 - ), - response_id=turn_result.model_response.response_id, - store=store_setting, - ) - reinterruption_items = [] - elif turn_session_items: - reinterruption_items = list(turn_session_items) - else: - # An emptied resolved turn settles what the session view - # left the batch: filtered outputs are gone with their - # calls, carried prior-turn pairs still land. - streamed_result._current_turn_persisted_item_count = ( - await settle_held_batch_for_emptied_turn( - run_state, - session, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, - 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, - save_items=_save_resumed_items, - items=reinterruption_items, - response_id=turn_result.model_response.response_id, - store_setting=store_setting, + await _persist_resumed_items( + turn_result, + defer_settlement=_should_defer_interrupted_session_items( + current_agent, run_config + ), + ) + _complete_stream_interruption( + streamed_result, interruptions=approvals_from_step(turn_result.next_step), processed_response=run_state._last_processed_response, ) @@ -1532,49 +1508,7 @@ 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) - # A detached exit folds the resolved session view into the - # held batch, and the fold drops the batch's copies of this - # turn's outputs so the view decides what rides to the - # reattach. - if session is None: - extend_held_session_write( - run_state, - run_items=turn_session_items, - run_items_are_the_session_view=True, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, - reasoning_item_id_policy=( - streamed_result._reasoning_item_id_policy - ), - ) - elif not turn_session_items: - # An emptied resolved turn settles what the session view - # left the batch: filtered outputs are gone with their - # calls, carried prior-turn pairs still land. - streamed_result._current_turn_persisted_item_count = ( - await settle_held_batch_for_emptied_turn( - run_state, - session, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, - 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, - store_setting, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=list(turn_result.pre_step_items), - ) + await _persist_resumed_items(turn_result) if current_span is not None: current_span.finish(reset_current=True) current_span = None @@ -1623,49 +1557,7 @@ async def _save_max_turns_items( break if isinstance(turn_result.next_step, NextStepRunAgain): - # A detached exit folds the resolved session view into the - # held batch, and the fold drops the batch's copies of this - # turn's outputs so the view decides what rides to the - # reattach. - if session is None: - extend_held_session_write( - run_state, - run_items=turn_session_items, - run_items_are_the_session_view=True, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, - reasoning_item_id_policy=( - streamed_result._reasoning_item_id_policy - ), - ) - elif not turn_session_items: - # An emptied resolved turn settles what the session view - # left the batch: filtered outputs are gone with their - # calls, carried prior-turn pairs still land. - streamed_result._current_turn_persisted_item_count = ( - await settle_held_batch_for_emptied_turn( - run_state, - session, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, - 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, - store_setting, - handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=list(turn_result.pre_step_items), - ) + await _persist_resumed_items(turn_result) run_state._current_step = NextStepRunAgain() if await _wait_for_streamed_turn_events_and_stop_if_cancelled( streamed_result @@ -2175,7 +2067,6 @@ def _record_max_turns_handler_output( run_items=turn_session_items, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, - filtered_context_items=turn_result.pre_step_items, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), ) elif parked_items_deferred and await _should_persist_stream_items( diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 04bca9b1f9..4bd9eab093 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -10,7 +10,7 @@ import hashlib import inspect import json -from collections import deque +from collections import Counter, deque from collections.abc import Sequence from typing import Any, cast @@ -86,7 +86,7 @@ "defer_interrupted_session_write", "extend_held_session_write", "take_held_session_write", - "settle_held_batch_for_emptied_turn", + "persist_resumed_turn", "resume_pending_session_write", "update_run_state_after_resume", "rewind_session_items", @@ -963,31 +963,63 @@ async def save_resumed_turn_items( return persisted_count + saved_count -async def settle_held_batch_for_emptied_turn( +async def persist_resumed_turn( + *, run_state: RunState | None, session: Session | None, - *, + items: list[RunItem], persisted_count: int, response_id: str | None, + defer_settlement: bool = False, reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, store: bool | None = None, wrapper: RunContextWrapper[Any] | None = None, handoff_input_filtered: bool = False, filtered_context_items: Sequence[RunItem] | None = None, + current_response_items: Sequence[RunItem] = (), ) -> int: - """Settle what an emptied resolved turn's session view left in the held batch. - - A handoff ``input_filter`` can drop every resolved item, and ``new_items`` is the - session-history axis by contract: an output the commit boundary folded this turn - is dropped with its call before this settle sees the batch, so a filtered pair - stays out of the Session. What still settles is carried prior-turn history (a - detached carry riding a checkpoint), which a later turn's filter is not entitled - to remove, under the same pairing rules as every other settle. + """Fold, defer, or settle a resumed turn's authoritative Session view. + + Detached turns retain the view for reattachment. A gated re-interruption keeps + deferring its write. Every other attached exit settles through the canonical + append path, even when a filter emptied the turn: earlier accepted history can + still remain in the held batch. """ + if handoff_input_filtered: + # Only survivors of this response can retain its held payload. An earlier + # response may contain identical text, including when a filter copies items. + current_keys = { + nested_history_run_item_occurrence_key(item) for item in current_response_items + } - {None} + filtered_context_items = [ + item + for item in filtered_context_items or () + if nested_history_run_item_occurrence_key(item) in current_keys + ] + if session is None: + extend_held_session_write( + run_state, + run_items=items, + run_items_are_the_session_view=True, + handoff_input_filtered=handoff_input_filtered, + filtered_context_items=filtered_context_items, + reasoning_item_id_policy=reasoning_item_id_policy, + ) + return persisted_count + if defer_settlement and run_state is not None: + defer_interrupted_session_write( + run_state, + session, + run_items=items, + reasoning_item_id_policy=reasoning_item_id_policy, + response_id=response_id, + store=store, + ) + return persisted_count return await save_resumed_turn_items( run_state=run_state, session=session, - items=[], + items=items, claim_held=True, handoff_input_filtered=handoff_input_filtered, filtered_context_items=filtered_context_items, @@ -1152,12 +1184,17 @@ def held_session_items_for_save( if (value := run_item_to_input_item(item, policy)) is not None ], ] - retained = {_held_view_fingerprint(item) for item in view} - current = [ - item - for item in current - if _held_pair_identity(item) is not None or _held_view_fingerprint(item) in retained - ] + retained = Counter(_held_view_fingerprint(item) for item in view) + kept = [] + for item in current: + if _held_pair_identity(item) is not None: + kept.append(item) + continue + fingerprint = _held_view_fingerprint(item) + if retained[fingerprint]: + kept.append(item) + retained[fingerprint] -= 1 + current = kept return [*prior, *current] diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index 0de2c561e8..97519d8c85 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -2632,6 +2632,13 @@ def _commit_tool_output(item: RunItem) -> None: def _checkpoint_new_items() -> None: if run_state is not None: run_state._generated_items = [*original_pre_step_items, *new_items] + # A retry skips checkpointed results, including hosted approval responses. + # Retain them in the withheld batch before later callbacks can fail. + extend_held_session_write( + run_state, + run_items=new_items, + reasoning_item_id_policy=run_state._reasoning_item_id_policy, + ) _register_tool_call_items(context_wrapper, new_items) _checkpoint_new_items() diff --git a/tests/deferred_session_writes/__init__.py b/tests/deferred_session_writes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/deferred_session_writes/helpers.py b/tests/deferred_session_writes/helpers.py new file mode 100644 index 0000000000..cb9eecff47 --- /dev/null +++ b/tests/deferred_session_writes/helpers.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import json +from typing import Any, Literal + +from agents import ( + Agent, + GuardrailFunctionOutput, + RunContextWrapper, + Runner, + RunResult, + RunResultStreaming, + RunState, + StopAtTools, + function_tool, + output_guardrail, +) +from agents.agent import Agent as AgentType +from agents.items import TResponseInputItem +from agents.memory.openai_conversations_session import OpenAIConversationsSession +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="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}" + + +@output_guardrail +async def always_fine( + ctx: RunContextWrapper[object], agent: AgentType[object], output: object +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) + + +@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") + + +_DEFERRING_BEHAVIOR = StopAtTools(stop_at_tool_names=["finish"]) + + +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( + [ + 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], + output_guardrails=[always_fine], + tool_use_behavior=tool_use_behavior, + ) + + +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, + ) + + +_PREAMBLE_TEXT = "About to write the thing." + + +def _make_terminal_tool_agent( + *, + 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] + if tripping: + 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=parked_response), + ] + ), + tools=[look_up, write_thing], + output_guardrails=guardrails if with_guardrails else [], + tool_use_behavior=StopAtTools(stop_at_tool_names=["write_thing"]), + ) + + +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 _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 _held_write(items: Any) -> Any: + return { + "session_id": "test", + "items": items, + "before": None, + "persisted_count": 0, + "held": True, + "current_response": {"turn": 0, "start": 0}, + } + + +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_output" and item.get("call_id") not in calls + ] + + +def _parked_pair(items: list[TResponseInputItem]) -> list[str]: + return [ + str(item.get("type")) + for item in items + if isinstance(item, dict) and item.get("call_id") == "call_PARKED" + ] + + +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 + + +_EXPECTED_PAIR = ["function_call", "function_call_output"] + + +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, + ) + + +class _RecordingConversationsSession(OpenAIConversationsSession): + """Stand-in carrying the Conversations class identity the settle checks. + + 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 __init__(self) -> None: + self.session_id = "conv-1" + self.added: list[TResponseInputItem] = [] + + 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 diff --git a/tests/deferred_session_writes/test_compaction.py b/tests/deferred_session_writes/test_compaction.py new file mode 100644 index 0000000000..c990ab8bdf --- /dev/null +++ b/tests/deferred_session_writes/test_compaction.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import ( + RunState, +) +from agents.items import TResponseInputItem +from tests.utils.simple_session import SimpleListSession + +from .helpers import ( + _EXPECTED_PAIR, + _call_ids, + _held_write, + _make_deferring_agent, + _make_multi_approval_agent, + _parked_pair, + _run, + _serialized_round_trip, +) + + +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_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_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_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 + state._current_turn = 0 + held: list[TResponseInputItem] = [ + {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_PARKED", "output": "ok"}, + ] + + state._pending_session_write = _held_write(held) + 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", + ) + + 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 + # 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 + # 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 +@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 +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}] diff --git a/tests/deferred_session_writes/test_filters.py b/tests/deferred_session_writes/test_filters.py new file mode 100644 index 0000000000..2939d56240 --- /dev/null +++ b/tests/deferred_session_writes/test_filters.py @@ -0,0 +1,579 @@ +from __future__ import annotations + +import copy +import json +from typing import Any + +import pytest + +from agents import ( + Agent, + HandoffInputData, + Runner, + RunState, + StopAtTools, + function_tool, + handoff, +) +from agents.exceptions import OutputGuardrailTripwireTriggered +from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call +from tests.utils.simple_session import SimpleListSession + +from .helpers import ( + _DEFERRING_BEHAVIOR, + _EXPECTED_PAIR, + _call_ids, + _make_deferring_agent, + _make_partial_filter_handoff_agent, + _make_terminal_tool_agent, + _make_two_park_agent, + _orphaned_outputs, + _parked_and_approved, + _parked_pair, + _run, + _serialized_round_trip, + always_crashes, + always_fine, + always_trips, + look_up, + write_thing, +) + + +@function_tool(needs_approval=True) +async def read_secret(query: str) -> str: + return "SECRET-VALUE-42" + + +def _make_secret_handoff_agent(input_filter: Any) -> Agent: + """A gated secret-bearing tool resolved into a handoff with the given filter.""" + from agents import handoff + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + return Agent( + name="deferred repro (secret)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("read_secret", {"query": "x"}, call_id="call_SECRET"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[read_secret], + handoffs=[handoff(target, input_filter=input_filter)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +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, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_emptied_resolved_turn_honors_the_filters_history_authority( + streamed: bool, +) -> None: + # ``HandoffInputData.new_items`` is the session-history axis by contract, so a + # filter that empties it is asking for nothing of this turn to persist. The + # approved tool ran, but persisting its pair from the held record would defeat the + # documented filter contract; callers who want the record keep ``new_items`` and + # filter model input through ``input_items`` instead. + session = SimpleListSession() + agent = _make_emptying_handoff_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + 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" not in calls, "the filter removed the pair from session history" + assert "call_HANDOFF" not in 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 +@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 "call_PARKED_2" not in calls, "the filtered output must not settle from the batch" + 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_librarys_own_filter_keeps_the_secret_out_of_the_session( + streamed: bool, +) -> None: + # ``remove_all_tools`` filters both ``new_items`` and ``input_items``: it wants + # tool data out of session history too. The held settle must not put back what the + # library's own filter removed. + from agents.extensions.handoff_filters import remove_all_tools + + session = SimpleListSession() + agent = _make_secret_handoff_agent(remove_all_tools) + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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 == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_input_items_only_filter_preserves_the_pair_in_session( + streamed: bool, +) -> None: + # ``input_items`` is the model-input axis: filtering it says nothing about session + # history, so the executed pair persists exactly as an unfiltered handoff would. + + def input_only(data: HandoffInputData) -> HandoffInputData: + return data.clone(input_items=()) + + session = SimpleListSession() + agent = _make_secret_handoff_agent(input_only) + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert any("SECRET-VALUE-42" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_SECRET" in calls and "call_SECRET" in outputs + assert calls - outputs == set() + + +@pytest.mark.asyncio +async def test_a_detached_filtered_handoff_drops_the_pair_before_the_reattach() -> None: + # The filter's authority does not lapse because the resume ran detached: the fold + # happened in this process, so the exit can still tell a current-turn output from + # carried history, and the batch must not smuggle the filtered pair to the + # reattaching entry settle. Cancellation only exists on the streaming runner. + from agents.extensions.handoff_filters import remove_all_tools + + session = SimpleListSession() + agent = _make_secret_handoff_agent(remove_all_tools) + 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, "the after-turn cancel must leave the batch riding" + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in pending["items"]), ( + "the filtered secret must not ride the checkpoint to the reattach" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_a_carried_pair_survives_a_filtered_handoff_on_a_later_run( + streamed: bool, +) -> None: + # After a checkpoint the folded set is empty on purpose: an output folded by an + # earlier process is carried prior-turn history, which a later turn's filter is + # not entitled to remove, exactly as the eager path cannot unpersist earlier + # turns. The reattaching entry settle keeps the carried batch whole. + 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 + assert {item.get("type") for item in pending["items"]} >= { + "function_call", + "function_call_output", + } + + state = await RunState.from_json(agent, json.loads(json.dumps(checkpoint))) + reattached = await _run(agent, state, session, streamed=streamed) + assert reattached.final_output == "done" + items = await session.get_items() + assert _parked_pair(items) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("approved", [False, True]) +async def test_a_filtered_unkeyed_sibling_stays_out_of_the_session( + streamed: bool, approved: bool +) -> None: + # The batch carries the parked response's unkeyed companions (an assistant + # preamble, an id-less reasoning item), and the filter's authority covers them + # exactly as it covers the outputs: removed from the view means removed from + # session history, keyed or not. + from agents import HandoffInputData, handoff + + def drops_preamble(data: HandoffInputData) -> HandoffInputData: + def keep(items: tuple) -> tuple: + return tuple(item for item in items if item.type != "message_output_item") + + 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")])]), + ) + agent = Agent( + name="deferred repro (unkeyed sibling)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + assistant_message("PREAMBLE-THE-FILTER-REMOVED"), + 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=drops_preamble)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + session = SimpleListSession() + parked = await _run(agent, "go", session, streamed=streamed) + state = await _serialized_round_trip(parked, agent) + interruption = state.get_interruptions()[0] + if approved: + state.approve(interruption) + else: + state.reject(interruption) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert not any("PREAMBLE-THE-FILTER-REMOVED" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_PARKED" in calls and "call_PARKED" in outputs + assert calls - outputs == set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_handoff_filter_applies_to_sibling_completed_before_approval(streamed: bool) -> None: + from agents import handoff + from agents.extensions.handoff_filters import remove_all_tools + + target = Agent( + name="target", model=ScriptedModel([ModelStep(output=[assistant_message("done")])]) + ) + agent = Agent( + name="source", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call("look_up", {"query": "x"}, call_id="call_LOOKUP"), + function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ] + ), + tools=[look_up, write_thing], + handoffs=[handoff(target, input_filter=remove_all_tools)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + result = await _run(agent, state, session, streamed=streamed) + assert result.final_output == "done" + assert not any( + item.get("type") in {"function_call", "function_call_output"} + for item in await session.get_items() + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_detached_terminal_guardrail_failure_keeps_unsettled_checkpoint( + streamed: bool, +) -> None: + from agents.exceptions import UserError + + session = SimpleListSession() + agent = _make_terminal_tool_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + agent.output_guardrails = [always_crashes] + with pytest.raises(RuntimeError, match="guardrail crashed"): + await _run(agent, state, None, streamed=streamed) + + assert state._pending_session_write is not None + agent.output_guardrails = [always_fine] + # A terminal side effect without a completed save must fail closed on reload, + # rather than allowing a retry that forgets the executed tool. + with pytest.raises(UserError, match="pending Session write is invalid"): + await RunState.from_json(agent, state.to_json()) + assert _parked_pair(await session.get_items()) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_later_tripwire_preserves_accepted_detached_history(streamed: bool) -> None: + agent = _make_two_park_agent() + agent.tool_use_behavior = StopAtTools(stop_at_tool_names=["write_other"]) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + detached = await _run(agent, state, None, streamed=streamed) + state = await _serialized_round_trip(detached, agent) + state.approve(state.get_interruptions()[0]) + agent.output_guardrails = [always_trips] + + with pytest.raises(OutputGuardrailTripwireTriggered): + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert _call_ids(items) == ["call_A", "call_B"] + outputs = [item for item in items if item.get("type") == "function_call_output"] + assert [item.get("call_id") for item in outputs] == ["call_A", "call_B"] + assert outputs[0]["output"] == "wrote:a" + assert "other:b" not in json.dumps(items) + assert state._pending_session_write is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_detached_repark_terminal_settle_preserves_each_response_once( + streamed: bool, +) -> None: + agent = _make_two_park_agent() + agent.tool_use_behavior = StopAtTools(stop_at_tool_names=["write_other"]) + agent.model = ScriptedModel( + [ + ModelStep( + output=[ + assistant_message("FIRST-PREAMBLE"), + function_call("write_thing", {"query": "a"}, call_id="call_A"), + ] + ), + ModelStep( + output=[ + assistant_message("SECOND-PREAMBLE"), + function_call("write_other", {"query": "b"}, call_id="call_B"), + ] + ), + ] + ) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + detached = await _run(agent, state, None, streamed=streamed) + state = await _serialized_round_trip(detached, agent) + state.approve(state.get_interruptions()[0]) + result = await _run(agent, state, session, streamed=streamed) + assert result.final_output == "other:b" + history = await session.get_items() + for text in ("FIRST-PREAMBLE", "SECOND-PREAMBLE"): + assert sum(text in json.dumps(item) for item in history) == 1 + assert _call_ids(history) == ["call_A", "call_B"] + assert _orphaned_outputs(history) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_an_additive_filter_keeps_the_batchs_companions(streamed: bool) -> None: + # A filter that only appends removed nothing, so the parked response's unkeyed + # companions must persist: their absence from the resolved items says nothing, + # because they ride the filtered pre-step view, and only absence from the whole + # filtered view is the filter's verdict. + from agents import handoff + from agents.items import MessageOutputItem + + def additive(data: HandoffInputData) -> HandoffInputData: + injected = MessageOutputItem( + agent=Agent(name="filler", instructions="x"), + raw_item=assistant_message("INJECTED-BY-FILTER"), + ) + return data.clone(new_items=(*data.new_items, injected)) + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + agent = Agent( + name="deferred repro (additive filter)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + assistant_message("COMPANION-KEPT-BY-FILTER"), + 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=additive)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + session = SimpleListSession() + state = await _parked_and_approved(agent, session, streamed=streamed) + await _run(agent, state, session, streamed=streamed) + + items = await session.get_items() + assert any("COMPANION-KEPT-BY-FILTER" in json.dumps(item) for item in items) + calls = set(_call_ids(items)) + outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} + assert "call_PARKED" in calls and "call_PARKED" in outputs + + +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize( + "prior_text,current_count,removed_index,expected_count", + [ + ("SAME PREAMBLE", 1, -1, 1), + ("SAME PREAMBLE", 1, 0, 2), + ("EARLIER PREAMBLE", 2, -1, 1), + ], +) +async def test_handoff_filter_distinguishes_equal_preambles( + streamed: bool, prior_text: str, current_count: int, removed_index: int, expected_count: int +) -> None: + def filter_preamble(data: HandoffInputData) -> HandoffInputData: + messages = [ + i for i, item in enumerate(data.pre_handoff_items) if item.type == "message_output_item" + ] + assert len(messages) == 1 + current_count + removed = messages[removed_index] + return data.clone( + pre_handoff_items=tuple( + copy.deepcopy(item) for i, item in enumerate(data.pre_handoff_items) if i != removed + ) + ) + + target = Agent( + name="target", model=ScriptedModel([ModelStep(output=[assistant_message("done")])]) + ) + agent = Agent( + name="source", + model=ScriptedModel( + [ + ModelStep( + output=[ + assistant_message(prior_text), + function_call("look_up", {"query": "x"}, call_id="lookup"), + ] + ), + ModelStep( + output=[ + *[assistant_message("SAME PREAMBLE") for _ in range(current_count)], + function_call("write_thing", {"query": "x"}, call_id="write"), + function_call("transfer_to_target", {}, call_id="handoff"), + ] + ), + ] + ), + tools=[look_up, write_thing], + handoffs=[handoff(target, input_filter=filter_preamble)], + tool_use_behavior=_DEFERRING_BEHAVIOR, + output_guardrails=[always_fine], + ) + session = SimpleListSession() + parked = await _run(agent, "go", session, streamed=streamed) + assert sum(prior_text in json.dumps(item) for item in await session.get_items()) == 1 + state = await _serialized_round_trip(parked, agent) + state.approve(state.get_interruptions()[0]) + await _run(agent, state, session, streamed=streamed) + # Keep accepted prior history, and only the current occurrences the filter kept. + # Equal text in either an earlier response or this response cannot restore a removal. + assert ( + sum("SAME PREAMBLE" in json.dumps(item) for item in await session.get_items()) + == expected_count + ) diff --git a/tests/deferred_session_writes/test_max_turns.py b/tests/deferred_session_writes/test_max_turns.py new file mode 100644 index 0000000000..bf2250c712 --- /dev/null +++ b/tests/deferred_session_writes/test_max_turns.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from agents import ( + Agent, + RunContextWrapper, + Runner, +) +from agents.lifecycle import RunHooks +from agents.run import RunConfig +from agents.testing import ModelStep, ScriptedModel, function_call +from tests.utils.simple_session import SimpleListSession + +from .helpers import ( + _DEFERRING_BEHAVIOR, + _EXPECTED_PAIR, + _make_deferring_agent, + _parked_and_approved, + _parked_pair, + _run, + _serialized_round_trip, + always_fine, + look_up, + write_thing, +) + + +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 + + +@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: + # 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 _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 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("include_in_history", [False, True]) +async def test_max_turns_fallback_preserves_approved_tool_history( + streamed: bool, include_in_history: bool +) -> None: + from agents.run_error_handlers import ( + RunErrorHandlerInput, + RunErrorHandlerResult, + RunErrorHandlers, + ) + + session = SimpleListSession() + agent = _make_never_finishing_agent() + if streamed: + first = Runner.run_streamed(agent, "go", session=session, max_turns=3) + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(agent, "go", session=session, max_turns=3) + state = await _serialized_round_trip(first, agent) + state.approve(state.get_interruptions()[0]) + + async def fallback(data: RunErrorHandlerInput[Any]) -> RunErrorHandlerResult: + # Completed tool history is already durable before the handler runs. + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + return RunErrorHandlerResult( + final_output="stopped at max turns", include_in_history=include_in_history + ) + + handlers: RunErrorHandlers[Any] = {"max_turns": fallback} + if streamed: + result = Runner.run_streamed(agent, state, session=session, error_handlers=handlers) + async for _ in result.stream_events(): + pass + else: + result = await Runner.run(agent, state, session=session, error_handlers=handlers) + assert result.final_output == "stopped at max turns" + history = await session.get_items() + assert _parked_pair(history) == _EXPECTED_PAIR + assert ("stopped at max turns" in json.dumps(history)) == include_in_history + assert "pending_session_write" not in result.to_state().to_json() diff --git a/tests/deferred_session_writes/test_pending_write.py b/tests/deferred_session_writes/test_pending_write.py new file mode 100644 index 0000000000..591dda44a6 --- /dev/null +++ b/tests/deferred_session_writes/test_pending_write.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import cast + +import pytest + +from agents import ( + RunState, +) +from agents.items import TResponseInputItem +from agents.memory.openai_conversations_session import OpenAIConversationsSession +from tests.utils.simple_session import SimpleListSession + +from .helpers import ( + _EXPECTED_PAIR, + _FailingResumeSession, + _held_write, + _make_deferring_agent, + _parked_pair, + _RecordingConversationsSession, + _run, +) + + +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] + + +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 = 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", + "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_write=_held_write(held), + 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" + + +@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" + state._pending_session_write = _held_write(held) + 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, + ) + + # 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"]} + + +@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_write=_held_write([call, output]), + persisted_count=0, + response_id=None, + reasoning_item_id_policy=None, + ) + + assert count == len(await session.get_items()) + + +@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" + + +@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.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 + + +@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 diff --git a/tests/deferred_session_writes/test_recovery.py b/tests/deferred_session_writes/test_recovery.py new file mode 100644 index 0000000000..35b3074acc --- /dev/null +++ b/tests/deferred_session_writes/test_recovery.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +from openai.types.responses.response_output_item import McpApprovalRequest + +from agents import ( + Agent, + HostedMCPTool, + RunState, + ToolsToFinalOutputResult, + function_tool, +) +from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call +from tests.utils.simple_session import SimpleListSession + +from .helpers import ( + _DEFERRING_BEHAVIOR, + _EXPECTED_PAIR, + _call_ids, + _FailingResumeSession, + _make_deferring_agent, + _make_terminal_tool_agent, + _orphaned_outputs, + _parked_and_approved, + _parked_pair, + _RecordingConversationsSession, + _run, + always_fine, + look_up, +) + + +@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() + + +@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() + # 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 + ) + + 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()) + + +@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"]} + + +@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) + + +def _boom_extractor(ctx: Any) -> dict[str, Any]: + raise RuntimeError("extractor boom") + + +@function_tool(needs_approval=True, custom_data_extractor=_boom_extractor) +async def read_secret_with_failing_extractor(query: str) -> str: + return "SECRET-VALUE-42" + + +def _make_secret_failing_extractor_handoff_agent() -> Agent: + """A secret-bearing gated tool whose extractor crashes, resolved into a filtered handoff.""" + from agents import handoff + from agents.extensions.handoff_filters import remove_all_tools + + target = Agent( + name="target", + instructions="x", + model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), + ) + return Agent( + name="deferred repro (secret, failing extractor)", + instructions="x", + model=ScriptedModel( + [ + ModelStep( + output=[ + function_call( + "read_secret_with_failing_extractor", + {"query": "x"}, + call_id="call_SECRET", + ), + function_call("transfer_to_target", {}, call_id="call_HANDOFF"), + ] + ), + ModelStep(output=[assistant_message("done")]), + ] + ), + tools=[read_secret_with_failing_extractor], + handoffs=[handoff(target, input_filter=remove_all_tools)], + output_guardrails=[always_fine], + tool_use_behavior=_DEFERRING_BEHAVIOR, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_the_filters_authority_survives_a_json_retry_of_the_crashed_turn( + streamed: bool, +) -> None: + # A post-output callback crash leaves the folded output on the checkpoint, and the + # supported retry path serializes and reloads that state. The fold's ownership + # rides the record with the turn it belongs to, so the reloaded retry's filter + # keeps its authority over the turn it is re-running: the batch's copy is not + # pairing evidence and the filtered secret stays out of the Session. + session = SimpleListSession() + agent = _make_secret_failing_extractor_handoff_agent() + state = await _parked_and_approved(agent, session, streamed=streamed) + + with pytest.raises(Exception, match="extractor boom"): + await _run(agent, state, session, streamed=streamed) + + reloaded = await RunState.from_json(agent, json.loads(json.dumps(state.to_json()))) + retry = await _run(agent, reloaded, session, streamed=streamed) + assert retry.final_output == "done" + + items = await session.get_items() + assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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))}" + + +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 + + +@pytest.mark.parametrize("streamed", [False, True]) +async def test_checkpointed_mcp_approval_survives_callback_failure_and_json_retry( + streamed: bool, +) -> None: + executions = 0 + decisions = 0 + + @function_tool(needs_approval=True) + def write_once() -> str: + nonlocal executions + executions += 1 + return "written" + + def decide(context: Any, results: Any) -> ToolsToFinalOutputResult: + nonlocal decisions + decisions += 1 + if decisions == 1: + raise RuntimeError("decision failed") + return ToolsToFinalOutputResult(is_final_output=False, final_output=None) + + request = McpApprovalRequest( + id="mcpr_test", + type="mcp_approval_request", + server_label="srv", + name="remote", + arguments="{}", + ) + agent = Agent( + name="agent", + tools=[ + write_once, + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "srv", + "server_url": "https://example.com", + "require_approval": "always", + } + ), + ], + output_guardrails=[always_fine], + tool_use_behavior=decide, + model=ScriptedModel( + [ + ModelStep(output=[function_call("write_once", {}, call_id="local_write"), request]), + ModelStep(output=[assistant_message("done")]), + ] + ), + ) + session = SimpleListSession() + # ScriptedModel cannot synthesize MCP stream events; both public resume paths + # consume the same serialized checkpoint from the ordinary initial run. + parked = await _run(agent, "go", session, streamed=False) + state = await RunState.from_json(agent, parked.to_state().to_json()) + for approval in state.get_interruptions(): + state.approve(approval) + with pytest.raises(RuntimeError, match="decision failed"): + await _run(agent, state, session, streamed=streamed) + assert await session.get_items() == [{"role": "user", "content": "go"}] + + state = await RunState.from_json(agent, state.to_json()) + result = await _run(agent, state, session, streamed=streamed) + assert result.final_output == "done" + assert executions == 1 + history = await session.get_items() + mcp_items = [ + item + for item in history + if item.get("type") in ("mcp_approval_request", "mcp_approval_response") + ] + assert [item["type"] for item in mcp_items] == [ + "mcp_approval_request", + "mcp_approval_response", + ] + assert mcp_items[0]["id"] == "mcpr_test" + assert mcp_items[1]["approval_request_id"] == "mcpr_test" + assert mcp_items[1]["approve"] is True + assert [item["type"] for item in history if item.get("call_id") == "local_write"] == [ + "function_call", + "function_call_output", + ] diff --git a/tests/deferred_session_writes/test_resume.py b/tests/deferred_session_writes/test_resume.py new file mode 100644 index 0000000000..a544bed396 --- /dev/null +++ b/tests/deferred_session_writes/test_resume.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import pytest + +from agents import ( + Agent, + GuardrailFunctionOutput, + RunContextWrapper, + Runner, + RunState, + output_guardrail, +) +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 + +from .helpers import ( + _DEFERRING_BEHAVIOR, + _EXPECTED_PAIR, + _PREAMBLE_TEXT, + _call_ids, + _make_deferring_agent, + _make_multi_approval_agent, + _make_partial_filter_handoff_agent, + _make_terminal_tool_agent, + _make_two_park_agent, + _orphaned_outputs, + _parked_and_approved, + _parked_pair, + _run, + _serialized_round_trip, + always_fine, + look_up, + write_thing, +) + + +class _ContextRequiringSession(SimpleListSession): + """Track whether internal reads and writes carry the run's context wrapper.""" + + def __init__(self) -> None: + super().__init__() + self.wrapperless_operations = 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) + + 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) + + 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() + + +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) + + +@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 +@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) + + 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() + + 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" + ) + ) + + second = await _run(agent, state, session, streamed=streamed) + assert len(second.interruptions) == 1 + 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 = await _run(agent, state, session, streamed=streamed) + assert final.final_output == "done" + + items = await session.get_items() + 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 +@pytest.mark.parametrize("resume_with_guardrails", [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: + # 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 + ) + + 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_a_detached_resume_does_not_make_the_next_one_rewrite_the_session() -> None: + # 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 = await _run( + _make_multi_approval_agent(tool_use_behavior="run_llm_again"), + "go", + session, + streamed=True, + ) + assert len(parked.interruptions) == 2 + assert "call_PARKED" in _call_ids(await session.get_items()) + + deferring_agent = _make_multi_approval_agent() + state = await _serialized_round_trip(parked, deferring_agent) + state.approve( + next( + interruption + for interruption in state.get_interruptions() + if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" + ) + ) + detached = await _run(deferring_agent, state, None, streamed=True) + + state = await _serialized_round_trip(detached, deferring_agent) + for interruption in state.get_interruptions(): + state.approve(interruption) + await _run(deferring_agent, state, session, streamed=True) + + call_ids = _call_ids(await session.get_items()) + assert call_ids.count("call_PARKED") == 1 + assert call_ids.count("call_PARKED_2") == 1 + + +@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( + 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 +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 +@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 + # 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 +@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, with_preamble=True) + state = await _parked_and_approved( + _make_terminal_tool_agent(with_preamble=True), + 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 + # 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) + + 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 +@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 + 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) + # 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) == [] + assert _parked_pair(items) == _EXPECTED_PAIR + + +@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))}" + # The filter kept this pair, so the batch must deliver it to the reattach; losing + # it silently would look symmetric too. + assert "call_PARKED" in calls and "call_PARKED" in outputs + + +@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 + + +@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 _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. + + @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 diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index cd31c8a6d8..3f447aef64 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -4301,8 +4301,9 @@ async def test_tool() -> str: state.approve(first.interruptions[0]) observed_counts: list[int] = [] - run_loop_any = cast(Any, run_loop) - real_save_resumed = run_loop_any.save_resumed_turn_items + from agents.run_internal import session_persistence + + real_save_resumed = session_persistence.save_resumed_turn_items async def save_wrapper( *, @@ -4336,7 +4337,7 @@ async def save_wrapper( ) return int(result) - monkeypatch.setattr(run_loop_any, "save_resumed_turn_items", save_wrapper) + monkeypatch.setattr(session_persistence, "save_resumed_turn_items", save_wrapper) resumed = Runner.run_streamed(agent, state, session=session) await consume_stream(resumed) diff --git a/tests/test_deferred_interrupted_session_write.py b/tests/test_deferred_interrupted_session_write.py deleted file mode 100644 index acfde34747..0000000000 --- a/tests/test_deferred_interrupted_session_write.py +++ /dev/null @@ -1,2499 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Callable -from dataclasses import replace -from typing import Any, Literal, cast - -import pytest - -from agents import ( - Agent, - GuardrailFunctionOutput, - RunContextWrapper, - Runner, - RunResult, - RunResultStreaming, - RunState, - StopAtTools, - function_tool, - output_guardrail, -) -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 - - -@function_tool(name_override="write_thing", needs_approval=True) -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}" - - -@output_guardrail -async def always_fine( - ctx: RunContextWrapper[object], agent: AgentType[object], output: object -) -> GuardrailFunctionOutput: - return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False) - - -@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_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( - [ - 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], - output_guardrails=[always_fine], - tool_use_behavior=tool_use_behavior, - ) - - -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, - ) - - -_PREAMBLE_TEXT = "About to write the thing." - - -@function_tool(needs_approval=True) -async def read_secret(query: str) -> str: - return "SECRET-VALUE-42" - - -def _make_secret_handoff_agent(input_filter: Any) -> Agent: - """A gated secret-bearing tool resolved into a handoff with the given filter.""" - from agents import handoff - - target = Agent( - name="target", - instructions="x", - model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), - ) - return Agent( - name="deferred repro (secret)", - instructions="x", - model=ScriptedModel( - [ - ModelStep( - output=[ - function_call("read_secret", {"query": "x"}, call_id="call_SECRET"), - function_call("transfer_to_target", {}, call_id="call_HANDOFF"), - ] - ), - ModelStep(output=[assistant_message("done")]), - ] - ), - tools=[read_secret], - handoffs=[handoff(target, input_filter=input_filter)], - output_guardrails=[always_fine], - tool_use_behavior=_DEFERRING_BEHAVIOR, - ) - - -def _make_terminal_tool_agent( - *, - 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] - if tripping: - 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=parked_response), - ] - ), - tools=[look_up, write_thing], - output_guardrails=guardrails if with_guardrails else [], - tool_use_behavior=StopAtTools(stop_at_tool_names=["write_thing"]), - ) - - -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, - ) - - -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.""" - - def __init__(self) -> None: - super().__init__() - self.wrapperless_operations = 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) - - 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) - - 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() - - -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 _held_write(items: Any) -> Any: - return { - "session_id": "test", - "items": items, - "before": None, - "persisted_count": 0, - "held": True, - "current_response": {"turn": 0, "start": 0}, - } - - -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_output" and item.get("call_id") not in calls - ] - - -def _parked_pair(items: list[TResponseInputItem]) -> list[str]: - return [ - str(item.get("type")) - for item in items - if isinstance(item, dict) and item.get("call_id") == "call_PARKED" - ] - - -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 - - -_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 -@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) - - 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() - - 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" - ) - ) - - second = await _run(agent, state, session, streamed=streamed) - assert len(second.interruptions) == 1 - 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 = await _run(agent, state, session, streamed=streamed) - assert final.final_output == "done" - - items = await session.get_items() - 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 -@pytest.mark.parametrize("resume_with_guardrails", [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: - # 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 - ) - - 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_a_detached_resume_does_not_make_the_next_one_rewrite_the_session() -> None: - # 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 = await _run( - _make_multi_approval_agent(tool_use_behavior="run_llm_again"), - "go", - session, - streamed=True, - ) - assert len(parked.interruptions) == 2 - assert "call_PARKED" in _call_ids(await session.get_items()) - - deferring_agent = _make_multi_approval_agent() - state = await _serialized_round_trip(parked, deferring_agent) - state.approve( - next( - interruption - for interruption in state.get_interruptions() - if getattr(interruption.raw_item, "call_id", None) == "call_PARKED" - ) - ) - detached = await _run(deferring_agent, state, None, streamed=True) - - state = await _serialized_round_trip(detached, deferring_agent) - for interruption in state.get_interruptions(): - state.approve(interruption) - await _run(deferring_agent, state, session, streamed=True) - - call_ids = _call_ids(await session.get_items()) - assert call_ids.count("call_PARKED") == 1 - assert call_ids.count("call_PARKED_2") == 1 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_an_emptied_resolved_turn_honors_the_filters_history_authority( - streamed: bool, -) -> None: - # ``HandoffInputData.new_items`` is the session-history axis by contract, so a - # filter that empties it is asking for nothing of this turn to persist. The - # approved tool ran, but persisting its pair from the held record would defeat the - # documented filter contract; callers who want the record keep ``new_items`` and - # filter model input through ``input_items`` instead. - session = SimpleListSession() - agent = _make_emptying_handoff_agent() - state = await _parked_and_approved(agent, session, streamed=streamed) - 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" not in calls, "the filter removed the pair from session history" - assert "call_HANDOFF" not in 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 -@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 "call_PARKED_2" not in calls, "the filtered output must not settle from the batch" - 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( - 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 -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 -@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 - # 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 -@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, with_preamble=True) - state = await _parked_and_approved( - _make_terminal_tool_agent(with_preamble=True), - 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 - # 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) - - 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 -@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 - 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) - # 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) == [] - assert _parked_pair(items) == _EXPECTED_PAIR - - -@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() - - -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))}" - # The filter kept this pair, so the batch must deliver it to the reattach; losing - # it silently would look symmetric too. - assert "call_PARKED" in calls and "call_PARKED" in outputs - - -@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 - - -@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() - # 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 - ) - - 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()) - - -@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(OpenAIConversationsSession): - """Stand-in carrying the Conversations class identity the settle checks. - - 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 __init__(self) -> None: - self.session_id = "conv-1" - self.added: list[TResponseInputItem] = [] - - 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) - - -@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] - - -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 = 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", - "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_write=_held_write(held), - 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" - - -@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" - state._pending_session_write = _held_write(held) - 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, - ) - - # 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"]} - - -@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_write=_held_write([call, output]), - persisted_count=0, - response_id=None, - reasoning_item_id_policy=None, - ) - - 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" - - -@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 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_the_librarys_own_filter_keeps_the_secret_out_of_the_session( - streamed: bool, -) -> None: - # ``remove_all_tools`` filters both ``new_items`` and ``input_items``: it wants - # tool data out of session history too. The held settle must not put back what the - # library's own filter removed. - from agents.extensions.handoff_filters import remove_all_tools - - from .test_deferred_interrupted_session_write import _make_secret_handoff_agent - - session = SimpleListSession() - agent = _make_secret_handoff_agent(remove_all_tools) - state = await _parked_and_approved(agent, session, streamed=streamed) - await _run(agent, state, session, streamed=streamed) - - items = await session.get_items() - assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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 == set(), f"dangling calls: {sorted(map(str, calls - outputs))}" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_an_input_items_only_filter_preserves_the_pair_in_session( - streamed: bool, -) -> None: - # ``input_items`` is the model-input axis: filtering it says nothing about session - # history, so the executed pair persists exactly as an unfiltered handoff would. - from agents import HandoffInputData - - def input_only(data: HandoffInputData) -> HandoffInputData: - return data.clone(input_items=()) - - session = SimpleListSession() - agent = _make_secret_handoff_agent(input_only) - state = await _parked_and_approved(agent, session, streamed=streamed) - await _run(agent, state, session, streamed=streamed) - - items = await session.get_items() - assert any("SECRET-VALUE-42" in json.dumps(item) for item in items) - calls = set(_call_ids(items)) - outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} - assert "call_SECRET" in calls and "call_SECRET" in outputs - assert calls - outputs == set() - - -@pytest.mark.asyncio -async def test_a_detached_filtered_handoff_drops_the_pair_before_the_reattach() -> None: - # The filter's authority does not lapse because the resume ran detached: the fold - # happened in this process, so the exit can still tell a current-turn output from - # carried history, and the batch must not smuggle the filtered pair to the - # reattaching entry settle. Cancellation only exists on the streaming runner. - from agents.extensions.handoff_filters import remove_all_tools - - session = SimpleListSession() - agent = _make_secret_handoff_agent(remove_all_tools) - 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, "the after-turn cancel must leave the batch riding" - assert not any("SECRET-VALUE-42" in json.dumps(item) for item in pending["items"]), ( - "the filtered secret must not ride the checkpoint to the reattach" - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_a_carried_pair_survives_a_filtered_handoff_on_a_later_run( - streamed: bool, -) -> None: - # After a checkpoint the folded set is empty on purpose: an output folded by an - # earlier process is carried prior-turn history, which a later turn's filter is - # not entitled to remove, exactly as the eager path cannot unpersist earlier - # turns. The reattaching entry settle keeps the carried batch whole. - 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 - assert {item.get("type") for item in pending["items"]} >= { - "function_call", - "function_call_output", - } - - state = await RunState.from_json(agent, json.loads(json.dumps(checkpoint))) - reattached = await _run(agent, state, session, streamed=streamed) - assert reattached.final_output == "done" - items = await session.get_items() - assert _parked_pair(items) == _EXPECTED_PAIR - - -def _boom_extractor(ctx: Any) -> dict[str, Any]: - raise RuntimeError("extractor boom") - - -@function_tool(needs_approval=True, custom_data_extractor=_boom_extractor) -async def read_secret_with_failing_extractor(query: str) -> str: - return "SECRET-VALUE-42" - - -def _make_secret_failing_extractor_handoff_agent() -> Agent: - """A secret-bearing gated tool whose extractor crashes, resolved into a filtered handoff.""" - from agents import handoff - from agents.extensions.handoff_filters import remove_all_tools - - target = Agent( - name="target", - instructions="x", - model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), - ) - return Agent( - name="deferred repro (secret, failing extractor)", - instructions="x", - model=ScriptedModel( - [ - ModelStep( - output=[ - function_call( - "read_secret_with_failing_extractor", - {"query": "x"}, - call_id="call_SECRET", - ), - function_call("transfer_to_target", {}, call_id="call_HANDOFF"), - ] - ), - ModelStep(output=[assistant_message("done")]), - ] - ), - tools=[read_secret_with_failing_extractor], - handoffs=[handoff(target, input_filter=remove_all_tools)], - output_guardrails=[always_fine], - tool_use_behavior=_DEFERRING_BEHAVIOR, - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -@pytest.mark.parametrize("approved", [False, True]) -async def test_a_filtered_unkeyed_sibling_stays_out_of_the_session( - streamed: bool, approved: bool -) -> None: - # The batch carries the parked response's unkeyed companions (an assistant - # preamble, an id-less reasoning item), and the filter's authority covers them - # exactly as it covers the outputs: removed from the view means removed from - # session history, keyed or not. - from agents import HandoffInputData, handoff - - def drops_preamble(data: HandoffInputData) -> HandoffInputData: - def keep(items: tuple) -> tuple: - return tuple(item for item in items if item.type != "message_output_item") - - 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")])]), - ) - agent = Agent( - name="deferred repro (unkeyed sibling)", - instructions="x", - model=ScriptedModel( - [ - ModelStep( - output=[ - assistant_message("PREAMBLE-THE-FILTER-REMOVED"), - 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=drops_preamble)], - output_guardrails=[always_fine], - tool_use_behavior=_DEFERRING_BEHAVIOR, - ) - session = SimpleListSession() - parked = await _run(agent, "go", session, streamed=streamed) - state = await _serialized_round_trip(parked, agent) - interruption = state.get_interruptions()[0] - if approved: - state.approve(interruption) - else: - state.reject(interruption) - await _run(agent, state, session, streamed=streamed) - - items = await session.get_items() - assert not any("PREAMBLE-THE-FILTER-REMOVED" in json.dumps(item) for item in items) - calls = set(_call_ids(items)) - outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} - assert "call_PARKED" in calls and "call_PARKED" in outputs - assert calls - outputs == set() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_handoff_filter_applies_to_sibling_completed_before_approval(streamed: bool) -> None: - from agents import handoff - from agents.extensions.handoff_filters import remove_all_tools - - target = Agent( - name="target", model=ScriptedModel([ModelStep(output=[assistant_message("done")])]) - ) - agent = Agent( - name="source", - model=ScriptedModel( - [ - ModelStep( - output=[ - function_call("look_up", {"query": "x"}, call_id="call_LOOKUP"), - function_call("write_thing", {"query": "x"}, call_id="call_PARKED"), - function_call("transfer_to_target", {}, call_id="call_HANDOFF"), - ] - ), - ] - ), - tools=[look_up, write_thing], - handoffs=[handoff(target, input_filter=remove_all_tools)], - output_guardrails=[always_fine], - tool_use_behavior=_DEFERRING_BEHAVIOR, - ) - session = SimpleListSession() - state = await _parked_and_approved(agent, session, streamed=streamed) - result = await _run(agent, state, session, streamed=streamed) - assert result.final_output == "done" - assert not any( - item.get("type") in {"function_call", "function_call_output"} - for item in await session.get_items() - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_detached_terminal_guardrail_failure_keeps_unsettled_checkpoint( - streamed: bool, -) -> None: - from agents.exceptions import UserError - - session = SimpleListSession() - agent = _make_terminal_tool_agent() - state = await _parked_and_approved(agent, session, streamed=streamed) - agent.output_guardrails = [always_crashes] - with pytest.raises(RuntimeError, match="guardrail crashed"): - await _run(agent, state, None, streamed=streamed) - - assert state._pending_session_write is not None - agent.output_guardrails = [always_fine] - # A terminal side effect without a completed save must fail closed on reload, - # rather than allowing a retry that forgets the executed tool. - with pytest.raises(UserError, match="pending Session write is invalid"): - await RunState.from_json(agent, state.to_json()) - assert _parked_pair(await session.get_items()) == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_later_tripwire_preserves_accepted_detached_history(streamed: bool) -> None: - agent = _make_two_park_agent() - agent.tool_use_behavior = StopAtTools(stop_at_tool_names=["write_other"]) - session = SimpleListSession() - state = await _parked_and_approved(agent, session, streamed=streamed) - detached = await _run(agent, state, None, streamed=streamed) - state = await _serialized_round_trip(detached, agent) - state.approve(state.get_interruptions()[0]) - agent.output_guardrails = [always_trips] - - with pytest.raises(OutputGuardrailTripwireTriggered): - await _run(agent, state, session, streamed=streamed) - - items = await session.get_items() - assert _call_ids(items) == ["call_A", "call_B"] - outputs = [item for item in items if item.get("type") == "function_call_output"] - assert [item.get("call_id") for item in outputs] == ["call_A", "call_B"] - assert outputs[0]["output"] == "wrote:a" - assert "other:b" not in json.dumps(items) - assert state._pending_session_write is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_detached_repark_terminal_settle_preserves_each_response_once( - streamed: bool, -) -> None: - agent = _make_two_park_agent() - agent.tool_use_behavior = StopAtTools(stop_at_tool_names=["write_other"]) - agent.model = ScriptedModel( - [ - ModelStep( - output=[ - assistant_message("FIRST-PREAMBLE"), - function_call("write_thing", {"query": "a"}, call_id="call_A"), - ] - ), - ModelStep( - output=[ - assistant_message("SECOND-PREAMBLE"), - function_call("write_other", {"query": "b"}, call_id="call_B"), - ] - ), - ] - ) - session = SimpleListSession() - state = await _parked_and_approved(agent, session, streamed=streamed) - detached = await _run(agent, state, None, streamed=streamed) - state = await _serialized_round_trip(detached, agent) - state.approve(state.get_interruptions()[0]) - result = await _run(agent, state, session, streamed=streamed) - assert result.final_output == "other:b" - history = await session.get_items() - for text in ("FIRST-PREAMBLE", "SECOND-PREAMBLE"): - assert sum(text in json.dumps(item) for item in history) == 1 - assert _call_ids(history) == ["call_A", "call_B"] - assert _orphaned_outputs(history) == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_an_additive_filter_keeps_the_batchs_companions(streamed: bool) -> None: - # A filter that only appends removed nothing, so the parked response's unkeyed - # companions must persist: their absence from the resolved items says nothing, - # because they ride the filtered pre-step view, and only absence from the whole - # filtered view is the filter's verdict. - from agents import HandoffInputData, handoff - from agents.items import MessageOutputItem - - def additive(data: HandoffInputData) -> HandoffInputData: - injected = MessageOutputItem( - agent=Agent(name="filler", instructions="x"), - raw_item=assistant_message("INJECTED-BY-FILTER"), - ) - return data.clone(new_items=(*data.new_items, injected)) - - target = Agent( - name="target", - instructions="x", - model=ScriptedModel([ModelStep(output=[assistant_message("done")])]), - ) - agent = Agent( - name="deferred repro (additive filter)", - instructions="x", - model=ScriptedModel( - [ - ModelStep( - output=[ - assistant_message("COMPANION-KEPT-BY-FILTER"), - 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=additive)], - output_guardrails=[always_fine], - tool_use_behavior=_DEFERRING_BEHAVIOR, - ) - session = SimpleListSession() - state = await _parked_and_approved(agent, session, streamed=streamed) - await _run(agent, state, session, streamed=streamed) - - items = await session.get_items() - assert any("COMPANION-KEPT-BY-FILTER" in json.dumps(item) for item in items) - calls = set(_call_ids(items)) - outputs = {item.get("call_id") for item in items if item.get("type") == "function_call_output"} - assert "call_PARKED" in calls and "call_PARKED" in outputs - - -@pytest.mark.asyncio -@pytest.mark.parametrize("streamed", [False, True]) -async def test_the_filters_authority_survives_a_json_retry_of_the_crashed_turn( - streamed: bool, -) -> None: - # A post-output callback crash leaves the folded output on the checkpoint, and the - # supported retry path serializes and reloads that state. The fold's ownership - # rides the record with the turn it belongs to, so the reloaded retry's filter - # keeps its authority over the turn it is re-running: the batch's copy is not - # pairing evidence and the filtered secret stays out of the Session. - session = SimpleListSession() - agent = _make_secret_failing_extractor_handoff_agent() - state = await _parked_and_approved(agent, session, streamed=streamed) - - with pytest.raises(Exception, match="extractor boom"): - await _run(agent, state, session, streamed=streamed) - - reloaded = await RunState.from_json(agent, json.loads(json.dumps(state.to_json()))) - retry = await _run(agent, reloaded, session, streamed=streamed) - assert retry.final_output == "done" - - items = await session.get_items() - assert not any("SECRET-VALUE-42" in json.dumps(item) for item in 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))}" - - -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_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_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_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 - state._current_turn = 0 - held: list[TResponseInputItem] = [ - {"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}, - {"type": "function_call_output", "call_id": "call_PARKED", "output": "ok"}, - ] - - state._pending_session_write = _held_write(held) - 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", - ) - - 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 - # 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 - # 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 -@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.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: - # 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 - - -@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 7d13f71d41c94ec6b65baf529dc88f23d8b841af Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 28 Sep 2026 08:30:56 -0700 Subject: [PATCH 37/38] fix(sessions): preserve response storage policy across approvals --- src/agents/run.py | 7 + src/agents/run_internal/run_loop.py | 11 +- .../run_internal/session_persistence.py | 35 ++-- .../test_compaction.py | 171 ++++++++++++++++++ .../test_pending_write.py | 43 ----- 5 files changed, 210 insertions(+), 57 deletions(-) diff --git a/src/agents/run.py b/src/agents/run.py index 23b7f459b7..0927f132e1 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -149,6 +149,7 @@ prepare_input_with_session, reconcile_nested_history_owned_session_item_refs, resume_pending_session_write, + resumed_response_store, resumed_turn_items, save_result_to_session, session_items_for_turn, @@ -1135,6 +1136,10 @@ def _mark_response_hooks_started() -> None: error_handlers=error_handlers, ) + store_setting = resumed_response_store( + run_state, turn_result.model_response.response_id, store_setting + ) + if run_state._last_processed_response is not None: tool_use_tracker.record_processed_response( current_agent, @@ -2160,6 +2165,8 @@ async def _save_max_turns_handler_output( extend_held_session_write( run_state, run_items=session_items_for_turn(turn_result), + response_id=turn_result.model_response.response_id, + store=store_setting, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=(run_state._reasoning_item_id_policy), diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 3f625a938a..e2fc494308 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -188,6 +188,7 @@ reconcile_nested_history_owned_session_item_refs, record_compaction_model_response, resume_pending_session_write, + resumed_response_store, resumed_turn_items, rewind_session_items, save_result_to_session, @@ -1456,9 +1457,11 @@ async def _save_max_turns_items( stream_step_items_to_queue( list(turn_session_items), streamed_result._event_queue ) - store_setting = current_agent.model_settings.resolve( - run_config.model_settings - ).store + store_setting = resumed_response_store( + run_state, + turn_result.model_response.response_id, + current_agent.model_settings.resolve(run_config.model_settings).store, + ) # The non-streaming resume path extends its run-wide lists before finalizing # but skips a resumed turn that loops back to the model, so a guardrail that @@ -2065,6 +2068,8 @@ def _record_max_turns_handler_output( extend_held_session_write( run_state, run_items=turn_session_items, + response_id=turn_result.model_response.response_id, + store=store_setting, run_items_are_the_session_view=True, handoff_input_filtered=turn_result.handoff_input_filtered, reasoning_item_id_policy=(streamed_result._reasoning_item_id_policy), diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py index 4bd9eab093..16dcb2a3c2 100644 --- a/src/agents/run_internal/session_persistence.py +++ b/src/agents/run_internal/session_persistence.py @@ -963,6 +963,16 @@ async def save_resumed_turn_items( return persisted_count + saved_count +def resumed_response_store( + run_state: RunState | None, response_id: str | None, store: bool | None +) -> bool | None: + """Resolve response-owned storage before settlement or redaction claims its batch.""" + pending = run_state._pending_session_write if run_state is not None else None + if pending is not None and pending.get("held") and pending.get("response_id") == response_id: + return pending.get("store", store) + return store + + async def persist_resumed_turn( *, run_state: RunState | None, @@ -1281,6 +1291,14 @@ def defer_interrupted_session_write( ) if session_id is None: return + # Storage belongs to the response, not the later run resolving approvals. + # A fresh parked response advances the frontier; extensions retain its setting. + if ( + pending is not None + and pending.get("current_response", {}).get("turn") == run_state._current_turn + ): + response_id = pending.get("response_id", response_id) + store = pending.get("store", store) record: _PendingSessionWrite = { "session_id": session_id, "items": copy.deepcopy(items), @@ -1289,17 +1307,8 @@ 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, 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, + "response_id": response_id, + "store": store, "reasoning_item_id_policy": reasoning_item_id_policy, } record["current_response"] = {"turn": run_state._current_turn, "start": len(prior)} @@ -1311,6 +1320,8 @@ def extend_held_session_write( *, run_items: Sequence[RunItem], reasoning_item_id_policy: ReasoningItemIdPolicy | None = None, + response_id: str | None = None, + store: bool | None = None, run_items_are_the_session_view: bool = False, handoff_input_filtered: bool = False, filtered_context_items: Sequence[RunItem] | None = None, @@ -1333,6 +1344,8 @@ def extend_held_session_write( None, run_items=run_items, reasoning_item_id_policy=reasoning_item_id_policy, + response_id=response_id, + store=store, run_items_are_the_session_view=run_items_are_the_session_view, handoff_input_filtered=handoff_input_filtered, filtered_context_items=filtered_context_items, diff --git a/tests/deferred_session_writes/test_compaction.py b/tests/deferred_session_writes/test_compaction.py index c990ab8bdf..2b85570f2d 100644 --- a/tests/deferred_session_writes/test_compaction.py +++ b/tests/deferred_session_writes/test_compaction.py @@ -327,3 +327,174 @@ async def test_the_entry_settle_defers_with_the_recorded_store() -> None: await resume_pending_session_write(state, session) # type: ignore[arg-type] assert session.compactions == [{"deferred": "resp_parked", "store": True}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("completion", ["continue", "terminal", "blocked"]) +@pytest.mark.parametrize( + "parked_store,resumed_store", [(False, True), (True, False), (None, False)] +) +async def test_approval_settlement_preserves_response_storage_mode( + streamed: bool, completion: str, parked_store: bool | None, resumed_store: bool +) -> None: + from agents import Agent, ModelSettings, RunConfig, Runner, StopAtTools + from agents.exceptions import OutputGuardrailTripwireTriggered + from agents.memory import OpenAIResponsesCompactionSession + from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call + + from .helpers import always_fine, always_trips, write_thing + + observed: list[tuple[str, str]] = [] + + def should_compact(context: dict[str, Any]) -> bool: + observed.append((context["response_id"], context["compaction_mode"])) + return False + + session = OpenAIResponsesCompactionSession( + "test", underlying_session=SimpleListSession(), should_trigger_compaction=should_compact + ) + expected_mode = "input" if parked_store is False else "previous_response_id" + + def next_response(call: Any) -> ModelStep: + # The original response must settle before another model request starts. + assert observed == [("resp_parked", expected_mode)] + return ModelStep(output=[assistant_message("done")], response_id="resp_new") + + agent = Agent( + name="storage mode", + tools=[write_thing], + output_guardrails=[always_fine], + tool_use_behavior=StopAtTools( + stop_at_tool_names=["finish" if completion == "continue" else "write_thing"] + ), + model=ScriptedModel( + [ + ModelStep( + output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")], + response_id="resp_parked", + ), + ModelStep.respond(next_response), + ] + ), + ) + initial_config = RunConfig(model_settings=ModelSettings(store=parked_store)) + if streamed: + first = Runner.run_streamed(agent, "go", session=session, run_config=initial_config) + async for _ in first.stream_events(): + pass + else: + first = await Runner.run(agent, "go", session=session, run_config=initial_config) + state = await _serialized_round_trip(first, agent) + state.approve(state.get_interruptions()[0]) + resumed_config = RunConfig(model_settings=ModelSettings(store=resumed_store)) + if completion == "blocked": + agent.output_guardrails = [always_trips] + + async def resume() -> None: + if streamed: + resumed = Runner.run_streamed(agent, state, session=session, run_config=resumed_config) + async for _ in resumed.stream_events(): + pass + else: + await Runner.run(agent, state, session=session, run_config=resumed_config) + + if completion == "blocked": + with pytest.raises(OutputGuardrailTripwireTriggered): + await resume() + else: + await resume() + assert observed[0] == ("resp_parked", expected_mode) + assert _parked_pair(await session.get_items()) == _EXPECTED_PAIR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +async def test_detached_repark_advances_response_storage_metadata(streamed: bool) -> None: + from agents import Agent, ModelSettings, RunConfig, Runner, StopAtTools + from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call + + from .helpers import always_fine, write_other, write_thing + + session = _CompactionRecordingSession() + agent = Agent( + name="new response frontier", + tools=[write_thing, write_other], + output_guardrails=[always_fine], + tool_use_behavior=StopAtTools(stop_at_tool_names=["finish"]), + model=ScriptedModel( + [ + ModelStep( + output=[function_call("write_thing", {"query": "a"}, call_id="call_A")], + response_id="resp_a", + ), + ModelStep( + output=[function_call("write_other", {"query": "b"}, call_id="call_B")], + response_id="resp_b", + ), + ModelStep(output=[assistant_message("done")], response_id="resp_c"), + ] + ), + ) + + async def run(run_input: Any, attached: bool, store: bool) -> Any: + config = RunConfig(model_settings=ModelSettings(store=store)) + if streamed: + result = Runner.run_streamed( + agent, run_input, session=session if attached else None, run_config=config + ) + async for _ in result.stream_events(): + pass + return result + return await Runner.run( + agent, run_input, session=session if attached else None, run_config=config + ) + + first = await run("go", True, True) + state = await _serialized_round_trip(first, agent) + state.approve(state.get_interruptions()[0]) + second = await run(state, False, False) + state = await _serialized_round_trip(second, agent) + state.approve(state.get_interruptions()[0]) + await run(state, True, True) + + assert session.compactions == [ + {"deferred": "resp_b", "store": False}, + {"response_id": "resp_c", "force": False, "store": True}, + ] + assert set(_call_ids(await session.get_items())) == {"call_A", "call_B"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streamed", [False, True]) +@pytest.mark.parametrize("parked_store", [None, False, True]) +async def test_partial_approval_repark_keeps_response_storage_setting( + streamed: bool, parked_store: bool | None +) -> None: + from agents import ModelSettings, RunConfig, Runner + + session = _CompactionRecordingSession() + agent = _make_multi_approval_agent() + + async def run(run_input: Any, store: bool | None) -> Any: + config = RunConfig(model_settings=ModelSettings(store=store)) + if streamed: + result = Runner.run_streamed(agent, run_input, session=session, run_config=config) + async for _ in result.stream_events(): + pass + return result + return await Runner.run(agent, run_input, session=session, run_config=config) + + first = await run("go", parked_store) + state = await _serialized_round_trip(first, agent) + state.approve(state.get_interruptions()[0]) + second = await run(state, not parked_store) + assert len(second.interruptions) == 1 + assert session.compactions == [] + state = await _serialized_round_trip(second, agent) + state.approve(state.get_interruptions()[0]) + await run(state, not parked_store) + assert session.compactions[0] == { + "deferred": first.raw_responses[-1].response_id, + "store": parked_store, + } diff --git a/tests/deferred_session_writes/test_pending_write.py b/tests/deferred_session_writes/test_pending_write.py index 591dda44a6..b58e96a7d3 100644 --- a/tests/deferred_session_writes/test_pending_write.py +++ b/tests/deferred_session_writes/test_pending_write.py @@ -288,49 +288,6 @@ async def test_the_settled_count_matches_what_the_append_actually_wrote() -> Non assert count == len(await session.get_items()) -@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" - - @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 From 1c16e5b2d9b9d1f9ec411f7b39f88d07536cacd4 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 28 Sep 2026 09:18:47 -0700 Subject: [PATCH 38/38] fix(sessions): synchronize turn before detached approval park --- src/agents/run.py | 4 ++++ tests/deferred_session_writes/test_compaction.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/agents/run.py b/src/agents/run.py index 0927f132e1..edc092e9cc 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -2103,6 +2103,10 @@ async def _save_max_turns_handler_output( run_state._current_step = None return _finalize_result(result) elif isinstance(turn_result.next_step, NextStepInterruption): + if run_state is not None: + # Held registration must see the fresh response's turn, + # including detached resumes without output guardrails. + run_state._current_turn = current_turn if session_persistence_enabled and not input_guardrails_triggered( _attempt_input_guardrail_results() ): diff --git a/tests/deferred_session_writes/test_compaction.py b/tests/deferred_session_writes/test_compaction.py index 2b85570f2d..fae324c431 100644 --- a/tests/deferred_session_writes/test_compaction.py +++ b/tests/deferred_session_writes/test_compaction.py @@ -410,7 +410,10 @@ async def resume() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("streamed", [False, True]) -async def test_detached_repark_advances_response_storage_metadata(streamed: bool) -> None: +@pytest.mark.parametrize("disable_guardrails", [False, True]) +async def test_detached_repark_advances_response_storage_metadata( + streamed: bool, disable_guardrails: bool +) -> None: from agents import Agent, ModelSettings, RunConfig, Runner, StopAtTools from agents.testing import ModelStep, ScriptedModel, assistant_message, function_call @@ -453,6 +456,8 @@ async def run(run_input: Any, attached: bool, store: bool) -> Any: first = await run("go", True, True) state = await _serialized_round_trip(first, agent) state.approve(state.get_interruptions()[0]) + if disable_guardrails: + agent.output_guardrails = [] second = await run(state, False, False) state = await _serialized_round_trip(second, agent) state.approve(state.get_interruptions()[0])