Skip to content

Commit 2bbe535

Browse files
seratchOm-singhaI
andauthored
fix(sessions): preserve concurrent writes during compaction (#4736)
Co-authored-by: Om Singhal <wengsinghal@gmail.com>
1 parent 624bb35 commit 2bbe535

3 files changed

Lines changed: 453 additions & 25 deletions

File tree

src/agents/memory/openai_responses_compaction_session.py

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ def __init__(
142142
# Serialize wrapper mutations against compaction snapshot/replace/restore so a
143143
# cancellation rollback cannot rewrite past a newer concurrent write.
144144
self._mutation_lock = asyncio.Lock()
145+
# Runner persistence can carry this wrapper-local generation across the
146+
# append-to-compaction gap. A later wrapper mutation revokes that one
147+
# pending automatic replacement without inferring ownership from history.
148+
self._mutation_generation = 0
145149

146150
@property
147151
def client(self) -> AsyncOpenAI:
@@ -178,6 +182,35 @@ async def run_compaction(
178182
When a run context is provided, the billed compaction request contributes to
179183
that run's usage totals.
180184
"""
185+
# Keep one wrapper mutation boundary from the snapshot through replacement.
186+
# A concurrent add, pop, or clear waits here and then runs against the
187+
# compacted state instead of being overwritten by a stale replacement.
188+
async with self._mutation_lock:
189+
has_expected_generation = wrapper is not None and hasattr(
190+
wrapper, "_session_compaction_generation"
191+
)
192+
expected_generation = (
193+
getattr(wrapper, "_session_compaction_generation", None)
194+
if has_expected_generation
195+
else None
196+
)
197+
if has_expected_generation and (
198+
not isinstance(expected_generation, int)
199+
or expected_generation != self._mutation_generation
200+
):
201+
logger.warning(
202+
"Skipped compaction because Session history changed after this "
203+
"run appended its items."
204+
)
205+
return
206+
await self._run_compaction_locked(args, wrapper=wrapper)
207+
208+
async def _run_compaction_locked(
209+
self,
210+
args: OpenAIResponsesCompactionArgs | None,
211+
*,
212+
wrapper: RunContextWrapper[Any] | None,
213+
) -> None:
181214
if args and args.get("response_id"):
182215
self._response_id = args["response_id"]
183216
requested_mode = args.get("compaction_mode") if args else None
@@ -245,14 +278,18 @@ async def run_compaction(
245278
_normalize_compaction_output_items(compacted.output or [])
246279
)
247280

248-
async with self._mutation_lock:
249-
previous_items = await self._get_all_underlying_session_items()
281+
previous_items = await self._get_all_underlying_session_items()
282+
try:
250283
await self._replace_underlying_session_items(
251284
output_items=output_items,
252285
previous_items=previous_items,
253286
)
254-
self._compaction_candidate_items = select_compaction_candidate_items(output_items)
255-
self._session_items = output_items
287+
except (Exception, asyncio.CancelledError):
288+
self._mutation_generation += 1
289+
raise
290+
self._mutation_generation += 1
291+
self._compaction_candidate_items = select_compaction_candidate_items(output_items)
292+
self._session_items = output_items
256293

257294
logger.debug(
258295
"compact: done for %s (mode=%s, output=%s, candidates=%s)",
@@ -265,6 +302,14 @@ async def run_compaction(
265302
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
266303
return await self.underlying_session.get_items(limit)
267304

305+
async def _get_items_with_generation(
306+
self, limit: int | None = None
307+
) -> tuple[list[TResponseInputItem], int]:
308+
"""Read one Runner snapshot with its exact wrapper generation."""
309+
async with self._mutation_lock:
310+
items = await self.underlying_session.get_items(limit)
311+
return items, self._mutation_generation
312+
268313
async def _get_all_underlying_session_items(self) -> list[TResponseInputItem]:
269314
return await self.underlying_session.get_items(limit=_ALL_SESSION_ITEMS_LIMIT)
270315

@@ -410,37 +455,69 @@ def _clear_deferred_compaction(self) -> None:
410455
self._deferred_response_id = None
411456

412457
async def add_items(self, items: list[TResponseInputItem]) -> None:
458+
async with self._mutation_lock:
459+
await self._add_items_locked(items)
460+
461+
async def _add_items_with_generation(
462+
self,
463+
items: list[TResponseInputItem],
464+
*,
465+
expected_generation: int | None,
466+
) -> int | None:
467+
"""Append one Runner batch and retain ownership only when its read stayed current."""
468+
async with self._mutation_lock:
469+
owns_generation = expected_generation == self._mutation_generation
470+
await self._add_items_locked(items)
471+
return self._mutation_generation if owns_generation else None
472+
473+
async def _add_items_locked(self, items: list[TResponseInputItem]) -> None:
474+
try:
475+
await self.underlying_session.add_items(items)
476+
except (Exception, asyncio.CancelledError):
477+
# The backend may have committed before acknowledgement failed. Re-read its
478+
# authoritative history before compaction instead of retaining a stale cache.
479+
self._compaction_candidate_items = None
480+
self._session_items = None
481+
self._mutation_generation += 1
482+
raise
483+
self._mutation_generation += 1
484+
if self._compaction_candidate_items is not None:
485+
new_items = _normalize_compaction_session_items(items)
486+
new_candidates = select_compaction_candidate_items(new_items)
487+
if new_candidates:
488+
self._compaction_candidate_items.extend(new_candidates)
489+
if self._session_items is not None:
490+
self._session_items.extend(_normalize_compaction_session_items(items))
491+
492+
async def pop_item(self) -> TResponseInputItem | None:
413493
async with self._mutation_lock:
414494
try:
415-
await self.underlying_session.add_items(items)
495+
popped = await self.underlying_session.pop_item()
416496
except (Exception, asyncio.CancelledError):
417-
# The backend may have committed before acknowledgement failed. Re-read its
418-
# authoritative history before compaction instead of retaining a stale cache.
419497
self._compaction_candidate_items = None
420498
self._session_items = None
499+
self._mutation_generation += 1
421500
raise
422-
if self._compaction_candidate_items is not None:
423-
new_items = _normalize_compaction_session_items(items)
424-
new_candidates = select_compaction_candidate_items(new_items)
425-
if new_candidates:
426-
self._compaction_candidate_items.extend(new_candidates)
427-
if self._session_items is not None:
428-
self._session_items.extend(_normalize_compaction_session_items(items))
429-
430-
async def pop_item(self) -> TResponseInputItem | None:
431-
async with self._mutation_lock:
432-
popped = await self.underlying_session.pop_item()
433501
if popped:
434502
self._compaction_candidate_items = None
435503
self._session_items = None
504+
self._mutation_generation += 1
436505
return popped
437506

438507
async def clear_session(self) -> None:
439508
async with self._mutation_lock:
440-
await self.underlying_session.clear_session()
509+
try:
510+
await self.underlying_session.clear_session()
511+
except (Exception, asyncio.CancelledError):
512+
self._compaction_candidate_items = None
513+
self._session_items = None
514+
self._deferred_response_id = None
515+
self._mutation_generation += 1
516+
raise
441517
self._compaction_candidate_items = []
442518
self._session_items = []
443519
self._deferred_response_id = None
520+
self._mutation_generation += 1
444521

445522
async def _ensure_compaction_candidates(
446523
self,

src/agents/run_internal/session_persistence.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,17 @@ async def _session_get_items(
199199
limit: int | None | object = _SESSION_LIMIT_UNSET,
200200
*,
201201
wrapper: RunContextWrapper[Any] | None = None,
202+
capture_compaction_generation: bool = False,
202203
) -> list[TResponseInputItem]:
203204
"""Read session items while preserving the legacy method call shape."""
205+
get_with_generation = getattr(session, "_get_items_with_generation", None)
206+
if capture_compaction_generation and wrapper is not None and callable(get_with_generation):
207+
if limit is _SESSION_LIMIT_UNSET:
208+
result, generation = await _call_session_method(get_with_generation)
209+
else:
210+
result, generation = await _call_session_method(get_with_generation, limit=limit)
211+
wrapper._session_compaction_generation = generation # type: ignore[attr-defined]
212+
return cast(list[TResponseInputItem], result)
204213
wrapper = _get_session_wrapper(session, wrapper)
205214
if limit is _SESSION_LIMIT_UNSET:
206215
result = await _call_session_method(session.get_items, wrapper=wrapper)
@@ -216,6 +225,16 @@ async def _session_add_items(
216225
wrapper: RunContextWrapper[Any] | None = None,
217226
) -> None:
218227
"""Append session items while preserving the legacy method call shape."""
228+
add_with_generation = getattr(session, "_add_items_with_generation", None)
229+
if wrapper is not None and callable(add_with_generation):
230+
expected_generation = getattr(wrapper, "_session_compaction_generation", None)
231+
generation = await _call_session_method(
232+
add_with_generation,
233+
items,
234+
expected_generation=expected_generation,
235+
)
236+
wrapper._session_compaction_generation = generation # type: ignore[attr-defined]
237+
return
219238
wrapper = _get_session_wrapper(session, wrapper)
220239
await _call_session_method(session.add_items, items, wrapper=wrapper)
221240

@@ -360,9 +379,14 @@ async def prepare_input_with_session(
360379
session,
361380
limit=resolved_settings.limit,
362381
wrapper=wrapper,
382+
capture_compaction_generation=True,
363383
)
364384
else:
365-
history = await _session_get_items(session, wrapper=wrapper)
385+
history = await _session_get_items(
386+
session,
387+
wrapper=wrapper,
388+
capture_compaction_generation=True,
389+
)
366390
is_openai_conversation_session = isinstance(session, OpenAIConversationsSession)
367391
converted_history = [
368392
strip_internal_input_item_metadata(ensure_input_item_format(item)) for item in history
@@ -674,9 +698,13 @@ async def save_result_to_session(
674698
resumed_write_state._current_turn_persisted_item_count + saved_run_items_count
675699
),
676700
}
677-
await resume_pending_session_write(resumed_write_state, session, wrapper=wrapper)
701+
await resume_pending_session_write(
702+
resumed_write_state,
703+
session,
704+
wrapper=compaction_wrapper,
705+
)
678706
else:
679-
await _session_add_items(session, items_to_save, wrapper=wrapper)
707+
await _session_add_items(session, items_to_save, wrapper=compaction_wrapper)
680708

681709
if run_state is not None:
682710
run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count
@@ -801,7 +829,15 @@ def digests(items: Sequence[TResponseInputItem]) -> list[str]:
801829
append = True
802830
else:
803831
expected = before + digests(pending["items"])
804-
tail = await _session_get_items(session, limit=len(expected), wrapper=wrapper)
832+
committed_generation: int | None = None
833+
get_with_generation = getattr(session, "_get_items_with_generation", None)
834+
if wrapper is not None and callable(get_with_generation):
835+
tail, committed_generation = await _call_session_method(
836+
get_with_generation,
837+
limit=len(expected),
838+
)
839+
else:
840+
tail = await _session_get_items(session, limit=len(expected), wrapper=wrapper)
805841
observed = digests(tail)
806842
committed = observed == expected
807843
unchanged = observed[-len(before) :] == before if before else not observed
@@ -811,6 +847,8 @@ def digests(items: Sequence[TResponseInputItem]) -> list[str]:
811847
"Repair the original Session before resuming; do not rerun the completed tool."
812848
)
813849
append = unchanged
850+
if committed and committed_generation is not None and wrapper is not None:
851+
wrapper._session_compaction_generation = committed_generation # type: ignore[attr-defined]
814852
if append:
815853
# Backends may retain or transform their input; the durable checkpoint stays detached.
816854
await _session_add_items(session, copy.deepcopy(pending["items"]), wrapper=wrapper)

0 commit comments

Comments
 (0)