Skip to content

Commit e4ce522

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 14c8315 commit e4ce522

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
@@ -1943,12 +1943,6 @@ async def _save_max_turns_handler_output(
19431943

19441944
try:
19451945
if isinstance(turn_result.next_step, NextStepFinalOutput):
1946-
if session is None and run_state is not None:
1947-
# A detached completion has no Session to settle against
1948-
# and the run ends here, so the batch is discarded
1949-
# rather than left to invalidate the completed run's
1950-
# checkpoint. Mirrors the resumed final exit.
1951-
take_held_session_write(run_state)
19521946
if run_state is not None and _has_output_guardrails(
19531947
current_agent, run_config
19541948
):
@@ -2072,6 +2066,16 @@ async def _save_max_turns_handler_output(
20722066
wrapper=context_wrapper,
20732067
)
20742068

2069+
if session is None and run_state is not None:
2070+
# A detached completion has no Session to settle against
2071+
# and the run ends here, so the batch is discarded
2072+
# rather than left to invalidate the completed run's
2073+
# checkpoint. Only here, though: the guardrails and the
2074+
# final save above can raise, and a run that raises may
2075+
# still be retried or reattached, with the executed
2076+
# tool's call and output reachable only through it.
2077+
take_held_session_write(run_state)
2078+
20752079
# Ensure starting_input is not None and not RunState
20762080
final_output_result_input: str | list[TResponseInputItem] = (
20772081
normalized_starting_input

src/agents/run_internal/run_loop.py

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

@@ -796,6 +797,9 @@ async def finalize_max_turns_handler_output(
796797
output_guardrail_results,
797798
)
798799
except OutputGuardrailTripwireTriggered:
800+
# A blocked outcome is decided and nothing of the withheld batch may reach the
801+
# Session, exactly as every other tripwire path disposes of it.
802+
take_held_session_write(run_state)
799803
raise
800804
except Exception as guardrail_error:
801805
guardrail_error_is_redacted = _is_error_data_redacted(guardrail_error)
@@ -812,6 +816,7 @@ async def finalize_max_turns_handler_output(
812816

813817
if redacted_persistence_error is not None:
814818
raise redacted_persistence_error from None
819+
take_held_session_write(run_state)
815820
return validated_output, synthesized_item
816821

817822

@@ -1542,11 +1547,6 @@ async def _save_max_turns_items(
15421547
continue
15431548

15441549
if isinstance(turn_result.next_step, NextStepFinalOutput):
1545-
if session is None:
1546-
# A detached final output has no Session to settle against
1547-
# and the run ends here, so the batch is discarded rather
1548-
# than left to invalidate the completed run's checkpoint.
1549-
take_held_session_write(run_state)
15501550
await _finalize_streamed_final_output(
15511551
streamed_result=streamed_result,
15521552
agent=current_agent,
@@ -1567,6 +1567,16 @@ async def _save_max_turns_items(
15671567
)
15681568
if streamed_result._stored_exception is not None:
15691569
break
1570+
if session is None:
1571+
# A detached final output has no Session to settle against
1572+
# and the run ends here, so the batch is discarded rather
1573+
# than left to invalidate the completed run's checkpoint.
1574+
# Only here, though: the finalization above runs the hooks,
1575+
# the guardrails and the final save, any of which can raise,
1576+
# and a run that raises may still be retried or reattached
1577+
# with the executed tool's call and output reachable only
1578+
# through this batch.
1579+
take_held_session_write(run_state)
15701580
run_state._current_step = None
15711581
break
15721582

@@ -2046,12 +2056,6 @@ def _record_max_turns_handler_output(
20462056
if await _wait_for_streamed_turn_events_and_stop_if_cancelled(streamed_result):
20472057
break
20482058
elif isinstance(turn_result.next_step, NextStepFinalOutput):
2049-
if session is None:
2050-
# A detached completion has no Session to settle against and
2051-
# the run ends here, so the batch is discarded rather than
2052-
# left to invalidate the completed run's checkpoint. Mirrors
2053-
# the resumed final exit.
2054-
take_held_session_write(run_state)
20552059
await _finalize_streamed_final_output(
20562060
streamed_result=streamed_result,
20572061
agent=current_agent,
@@ -2068,6 +2072,15 @@ def _record_max_turns_handler_output(
20682072
)
20692073
if streamed_result._stored_exception is not None:
20702074
break
2075+
if session is None:
2076+
# A detached completion has no Session to settle against and
2077+
# the run ends here, so the batch is discarded rather than
2078+
# left to invalidate the completed run's checkpoint. Only here,
2079+
# though: the finalization above runs the hooks, the guardrails
2080+
# and the final save, any of which can raise, and a run that
2081+
# raises may still be retried or reattached with the executed
2082+
# tool's call and output reachable only through this batch.
2083+
take_held_session_write(run_state)
20712084
if run_state is not None:
20722085
run_state._current_step = None
20732086
break

src/agents/run_internal/session_persistence.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,11 +1089,15 @@ def defer_interrupted_session_write(
10891089
"held": True,
10901090
# The response the withheld batch belongs to, so the settle can run the same
10911091
# compaction bookkeeping the ordinary persistence path runs for it. An extend
1092-
# keeps the original response: the batch is that response's write.
1093-
"response_id": (pending.get("response_id") if pending is not None else None) or response_id,
1094-
"store": (pending.get("store") if pending is not None else None)
1095-
if (pending is not None and pending.get("store") is not None)
1096-
else store,
1092+
# keeps the original response: the batch is that response's write, and the
1093+
# settle resolves its compaction mode from that response's own storage setting.
1094+
# Presence decides, not truthiness: a park under the ordinary ``store=None``
1095+
# records a real value, and letting a re-interruption's setting overwrite it
1096+
# would resolve the original response's compaction mode from the wrong turn.
1097+
"response_id": pending["response_id"]
1098+
if (pending is not None and "response_id" in pending)
1099+
else response_id,
1100+
"store": pending["store"] if (pending is not None and "store" in pending) else store,
10971101
}
10981102
run_state._pending_session_write = record
10991103

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)