Skip to content

Commit b498f4b

Browse files
committed
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.
1 parent fd6311c commit b498f4b

4 files changed

Lines changed: 187 additions & 26 deletions

File tree

src/agents/run.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,12 +1973,6 @@ async def _save_max_turns_handler_output(
19731973

19741974
try:
19751975
if isinstance(turn_result.next_step, NextStepFinalOutput):
1976-
if session is None and run_state is not None:
1977-
# A detached completion has no Session to settle against
1978-
# and the run ends here, so the batch is discarded
1979-
# rather than left to invalidate the completed run's
1980-
# checkpoint. Mirrors the resumed final exit.
1981-
take_held_session_write(run_state)
19821976
if run_state is not None and _has_output_guardrails(
19831977
current_agent, run_config
19841978
):
@@ -2106,6 +2100,16 @@ async def _save_max_turns_handler_output(
21062100
if run_state is not None:
21072101
run_state._terminal_unrecoverable = False
21082102

2103+
if session is None and run_state is not None:
2104+
# A detached completion has no Session to settle against
2105+
# and the run ends here, so the batch is discarded
2106+
# rather than left to invalidate the completed run's
2107+
# checkpoint. Only here, though: the guardrails and the
2108+
# final save above can raise, and a run that raises may
2109+
# still be retried or reattached, with the executed
2110+
# tool's call and output reachable only through it.
2111+
take_held_session_write(run_state)
2112+
21092113
# Ensure starting_input is not None and not RunState
21102114
final_output_result_input: str | list[TResponseInputItem] = (
21112115
normalized_starting_input

src/agents/run_internal/run_loop.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -784,12 +784,13 @@ async def finalize_max_turns_handler_output(
784784
A max-turn handler ends the run, so a held Session write still standing here has
785785
no later gate-legal exit to settle it: it is discarded, exactly as a detached
786786
completion discards it, so the finished run's checkpoint stays loadable and both
787-
runners report the same terminal state.
787+
runners report the same terminal state. The discard waits for the run to actually
788+
end, which is either a completed finalization or a decided blocked outcome:
789+
validation, the final-output hooks and the guardrails can all raise, and a run that
790+
raises may still be retried or reattached, with the executed tool's call and output
791+
reachable only through this batch.
788792
"""
789793
validated_output = validate_handler_final_output(agent, output)
790-
# Only past the validation does the handler actually end the run; discarding above
791-
# it would throw the batch away on a rejection the streamed runner survives.
792-
take_held_session_write(run_state)
793794
output_text = format_final_output_text(agent, validated_output)
794795
synthesized_item = create_message_output_item(agent, output_text)
795796

@@ -805,6 +806,9 @@ async def finalize_max_turns_handler_output(
805806
output_guardrail_results,
806807
)
807808
except OutputGuardrailTripwireTriggered:
809+
# A blocked outcome is decided and nothing of the withheld batch may reach the
810+
# Session, exactly as every other tripwire path disposes of it.
811+
take_held_session_write(run_state)
808812
raise
809813
except Exception as guardrail_error:
810814
guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error)
@@ -821,6 +825,7 @@ async def finalize_max_turns_handler_output(
821825

822826
if redacted_persistence_error is not None:
823827
raise redacted_persistence_error from None
828+
take_held_session_write(run_state)
824829
return validated_output, synthesized_item
825830

826831

@@ -1552,11 +1557,6 @@ async def _save_max_turns_items(
15521557
continue
15531558

15541559
if isinstance(turn_result.next_step, NextStepFinalOutput):
1555-
if session is None:
1556-
# A detached final output has no Session to settle against
1557-
# and the run ends here, so the batch is discarded rather
1558-
# than left to invalidate the completed run's checkpoint.
1559-
take_held_session_write(run_state)
15601560
await _finalize_streamed_final_output(
15611561
streamed_result=streamed_result,
15621562
agent=current_agent,
@@ -1577,6 +1577,16 @@ async def _save_max_turns_items(
15771577
)
15781578
if streamed_result._stored_exception is not None:
15791579
break
1580+
if session is None:
1581+
# A detached final output has no Session to settle against
1582+
# and the run ends here, so the batch is discarded rather
1583+
# than left to invalidate the completed run's checkpoint.
1584+
# Only here, though: the finalization above runs the hooks,
1585+
# the guardrails and the final save, any of which can raise,
1586+
# and a run that raises may still be retried or reattached
1587+
# with the executed tool's call and output reachable only
1588+
# through this batch.
1589+
take_held_session_write(run_state)
15801590
run_state._current_step = None
15811591
break
15821592

@@ -2056,12 +2066,6 @@ def _record_max_turns_handler_output(
20562066
if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result):
20572067
break
20582068
elif isinstance(turn_result.next_step, NextStepFinalOutput):
2059-
if session is None:
2060-
# A detached completion has no Session to settle against and
2061-
# the run ends here, so the batch is discarded rather than
2062-
# left to invalidate the completed run's checkpoint. Mirrors
2063-
# the resumed final exit.
2064-
take_held_session_write(run_state)
20652069
await _finalize_streamed_final_output(
20662070
streamed_result=streamed_result,
20672071
agent=current_agent,
@@ -2078,6 +2082,15 @@ def _record_max_turns_handler_output(
20782082
)
20792083
if streamed_result._stored_exception is not None:
20802084
break
2085+
if session is None:
2086+
# A detached completion has no Session to settle against and
2087+
# the run ends here, so the batch is discarded rather than
2088+
# left to invalidate the completed run's checkpoint. Only here,
2089+
# though: the finalization above runs the hooks, the guardrails
2090+
# and the final save, any of which can raise, and a run that
2091+
# raises may still be retried or reattached with the executed
2092+
# tool's call and output reachable only through this batch.
2093+
take_held_session_write(run_state)
20812094
if run_state is not None:
20822095
run_state._current_step = None
20832096
break

src/agents/run_internal/session_persistence.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,11 +1124,15 @@ def defer_interrupted_session_write(
11241124
"held": True,
11251125
# The response the withheld batch belongs to, so the settle can run the same
11261126
# compaction bookkeeping the ordinary persistence path runs for it. An extend
1127-
# keeps the original response: the batch is that response's write.
1128-
"response_id": (pending.get("response_id") if pending is not None else None) or response_id,
1129-
"store": (pending.get("store") if pending is not None else None)
1130-
if (pending is not None and pending.get("store") is not None)
1131-
else store,
1127+
# keeps the original response: the batch is that response's write, and the
1128+
# settle resolves its compaction mode from that response's own storage setting.
1129+
# Presence decides, not truthiness: a park under the ordinary ``store=None``
1130+
# records a real value, and letting a re-interruption's setting overwrite it
1131+
# would resolve the original response's compaction mode from the wrong turn.
1132+
"response_id": pending["response_id"]
1133+
if (pending is not None and "response_id" in pending)
1134+
else response_id,
1135+
"store": pending["store"] if (pending is not None and "store" in pending) else store,
11321136
}
11331137
run_state._pending_session_write = record
11341138

tests/test_deferred_interrupted_session_write.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
from collections.abc import Callable
45
from dataclasses import replace
56
from typing import Any, Literal, cast
67

@@ -1347,6 +1348,145 @@ async def test_the_settled_count_matches_what_the_append_actually_wrote() -> Non
13471348
assert count == len(await session.get_items())
13481349

13491350

1351+
class _FinalOutputHookFailure(RunHooks[Any]):
1352+
"""Fail the run at the final-output hook, after the terminal step is decided."""
1353+
1354+
async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
1355+
raise RuntimeError("final output hook failed")
1356+
1357+
1358+
@pytest.mark.asyncio
1359+
async def test_a_failed_max_turns_finalization_keeps_the_held_record() -> None:
1360+
# The batch is disposed of when the run actually ends, not when the terminal step
1361+
# is chosen. Validation, the final-output hooks and the output guardrails all run
1362+
# after that choice and all can raise, and a run that raises may still be retried
1363+
# or reattached with the executed tool's call and output reachable only here.
1364+
from agents.run_internal.run_loop import finalize_max_turns_handler_output
1365+
1366+
session = SimpleListSession()
1367+
agent = _make_deferring_agent()
1368+
state = await _parked_and_approved(agent, session, streamed=False)
1369+
assert state._pending_session_write is not None
1370+
1371+
async def _no_save(items: list[Any]) -> None:
1372+
return None
1373+
1374+
with pytest.raises(RuntimeError):
1375+
await finalize_max_turns_handler_output(
1376+
agent=agent,
1377+
hooks=_FinalOutputHookFailure(),
1378+
run_config=RunConfig(tracing_disabled=True),
1379+
output="stopped at max turns",
1380+
context_wrapper=RunContextWrapper(context=None),
1381+
output_guardrail_results=[],
1382+
save_items_after_guardrails=_no_save,
1383+
include_in_history=False,
1384+
run_state=state,
1385+
)
1386+
1387+
assert state._pending_session_write is not None
1388+
1389+
1390+
def _make_deferring_agent_with_a_turn_after_the_resume() -> Agent:
1391+
"""A gated write whose resume runs one more model turn before finishing.
1392+
1393+
The extra turn moves the final output past the resumed boundary and onto the main
1394+
loop, which owns its own detached-completion disposal.
1395+
"""
1396+
return Agent(
1397+
name="deferred repro (turn after resume)",
1398+
instructions="Always call write_thing.",
1399+
model=ScriptedModel(
1400+
[
1401+
ModelStep(output=[function_call("look_up", {"query": "x"}, call_id="call_LOOKUP")]),
1402+
ModelStep(
1403+
output=[function_call("write_thing", {"query": "x"}, call_id="call_PARKED")]
1404+
),
1405+
ModelStep(output=[function_call("look_up", {"query": "y"}, call_id="call_AFTER")]),
1406+
ModelStep(output=[assistant_message("done")]),
1407+
]
1408+
),
1409+
tools=[look_up, write_thing],
1410+
output_guardrails=[always_fine],
1411+
tool_use_behavior=_DEFERRING_BEHAVIOR,
1412+
)
1413+
1414+
1415+
@pytest.mark.asyncio
1416+
@pytest.mark.parametrize("streamed", [False, True])
1417+
@pytest.mark.parametrize(
1418+
"make_agent",
1419+
[_make_deferring_agent, _make_deferring_agent_with_a_turn_after_the_resume],
1420+
ids=["final-on-the-resumed-turn", "final-on-a-later-turn"],
1421+
)
1422+
async def test_a_failed_detached_completion_keeps_the_held_record(
1423+
streamed: bool, make_agent: Callable[[], Agent]
1424+
) -> None:
1425+
# A detached completion discards the batch because the run ends there, but only
1426+
# once it has ended: the guardrails and the final save run after the terminal step
1427+
# is chosen, and a failure there leaves a checkpoint whose reattach is the batch's
1428+
# only remaining way into the Session.
1429+
from agents import output_guardrail
1430+
1431+
@output_guardrail
1432+
async def _fails(ctx: Any, agent: Agent, output: Any) -> GuardrailFunctionOutput:
1433+
raise RuntimeError("output guardrail failed")
1434+
1435+
session = SimpleListSession()
1436+
agent = make_agent()
1437+
state = await _parked_and_approved(agent, session, streamed=streamed)
1438+
assert state._pending_session_write is not None
1439+
agent.output_guardrails = [*agent.output_guardrails, _fails]
1440+
1441+
with pytest.raises(RuntimeError):
1442+
await _run(agent, state, None, streamed=streamed)
1443+
1444+
assert state._pending_session_write is not None
1445+
1446+
1447+
@pytest.mark.asyncio
1448+
@pytest.mark.parametrize("parked_store", [None, False, True])
1449+
async def test_a_re_park_keeps_the_storage_setting_the_response_was_produced_under(
1450+
parked_store: bool | None,
1451+
) -> None:
1452+
# The batch belongs to the parked response, and the settle resolves that
1453+
# response's compaction mode from this value. Presence decides, not truthiness: a
1454+
# park under the ordinary ``store=None`` records a real setting, and a
1455+
# re-interruption under a different one must not overwrite it.
1456+
from agents.run_internal.session_persistence import defer_interrupted_session_write
1457+
1458+
class _Session:
1459+
session_id = "s1"
1460+
1461+
state = object.__new__(RunState)
1462+
state._pending_session_write = {
1463+
"session_id": "s1",
1464+
"items": [
1465+
{"type": "function_call", "call_id": "call_PARKED", "name": "t", "arguments": "{}"}
1466+
],
1467+
"before": None,
1468+
"persisted_count": 1,
1469+
"held": True,
1470+
"response_id": "resp_parked",
1471+
"store": parked_store,
1472+
}
1473+
state._current_turn_persisted_item_count = 0
1474+
state._reasoning_item_id_policy = None
1475+
1476+
defer_interrupted_session_write(
1477+
state,
1478+
_Session(), # type: ignore[arg-type]
1479+
run_items=[],
1480+
reasoning_item_id_policy=None,
1481+
response_id="resp_reinterrupted",
1482+
store=not parked_store,
1483+
)
1484+
1485+
assert state._pending_session_write is not None
1486+
assert state._pending_session_write["store"] is parked_store
1487+
assert state._pending_session_write["response_id"] == "resp_parked"
1488+
1489+
13501490
class _CompactionRecordingSession(SimpleListSession):
13511491
"""Record the compaction bookkeeping a compaction-aware backend expects."""
13521492

0 commit comments

Comments
 (0)