fix(compression): correct prune boundary + exempt native compaction checkpoints

Two corrections on top of the #71077 base (the whole bug class):

1. Turn boundary = last USER message, not last assistant message. A Codex
   turn spans several assistant messages (assistant+tool_calls -> tool ->
   ... -> final assistant) whose reasoning items must replay together; the
   last-assistant boundary would strip reasoning mid-chain from the active
   turn (the gap flagged in PR #71077 review).

2. type="compaction" checkpoints (native server-side compaction, PR #81747)
   are exempt: they carry already-pruned history, not per-turn reasoning.
   Pruning filters items instead of popping the sidecar key.

Sibling site fixed in the same class: the Codex incomplete-continuation
dedup path blind-overwrote codex_reasoning_items on visually-duplicate
interim messages, which would drop the only copy of a checkpoint captured
on the earlier response. Extracted merge_interim_reasoning_items() into
agent/native_compaction.py; newer reasoning wins, prior checkpoints are
preserved unless the newer payload carries its own.
This commit is contained in:
Teknium
2026-08-08 11:55:05 -07:00
parent adf9549cdd
commit e00965a7e8
4 changed files with 254 additions and 18 deletions
+44 -17
View File
@@ -247,8 +247,8 @@ def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
def _prune_stale_reasoning_replay(messages: List[Dict[str, Any]]) -> int:
"""Strip stale replay fields (``codex_reasoning_items``) from retained
assistant messages older than the most recent assistant turn.
"""Strip stale per-turn replay items (``codex_reasoning_items``) from
assistant messages that belong to turns older than the active one.
During Codex/Responses sessions, every retained assistant message carries
encrypted reasoning blobs (``codex_reasoning_items``) that are only needed
@@ -260,32 +260,59 @@ def _prune_stale_reasoning_replay(messages: List[Dict[str, Any]]) -> int:
Operates in place on the fully assembled compacted message list. Returns
the number of messages that were pruned (for diagnostics). #71058.
The pruning rule is conservative: everything up to (but not including) the
*last* assistant message in the list gets its stale replay fields stripped.
The final assistant message retains its items because it is the most recent
turn and its replay chain may still be active. When there is no assistant
message at all (shouldn't happen in practice, but defensive) nothing is
stripped.
Two safety rules define the prune:
* **Turn boundary is the last user message, not the last assistant
message.** A single Codex turn spans several assistant messages
(assistant+tool_calls -> tool -> assistant+tool_calls -> ... -> final
assistant), and the Responses API requires the reasoning items that
bridge those function calls to be replayed together. Everything after
the last user message is the active turn and keeps its items; only
messages at or before that boundary are stale. (An earlier draft used
the last assistant message and would have stripped reasoning mid-chain
from the in-flight turn.)
* **Native compaction checkpoints are exempt.** ``type: "compaction"``
items in the same sidecar are the server-side stand-in for already
pruned history (see ``agent/native_compaction.py``) — cumulative
context carriers, not per-turn reasoning. They must survive on every
retained message, so pruning filters items instead of popping the key.
"""
# Find the last assistant message index — everything before it is stale.
last_asst_idx = -1
# Find the last real user message — everything after it is the active
# turn. Synthetic continuation rows and tool results never mark a turn
# boundary.
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, dict) and msg.get("role") == "assistant":
last_asst_idx = i
if isinstance(msg, dict) and msg.get("role") == "user":
last_user_idx = i
break
if last_asst_idx <= 0:
# No assistant message, or only one (nothing to prune).
if last_user_idx < 0:
# No user boundary found — cannot distinguish the active turn, so
# prune nothing (fail open toward correctness, not size).
return 0
pruned = 0
for i in range(last_asst_idx):
for i in range(last_user_idx):
msg = messages[i]
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for key in _STALE_REPLAY_PRUNE_KEYS:
if msg.pop(key, None) is not None:
pruned += 1
items = msg.get(key)
if not isinstance(items, list) or not items:
continue
kept = [
item
for item in items
if isinstance(item, dict) and item.get("type") == "compaction"
]
if len(kept) == len(items):
continue # nothing stale in this sidecar
if kept:
msg[key] = kept
else:
msg.pop(key, None)
pruned += 1
return pruned
+14 -1
View File
@@ -6159,7 +6159,20 @@ def run_conversation(
"codex_message_items",
):
if _key in interim_msg:
last_msg[_key] = interim_msg[_key]
if _key == "codex_reasoning_items":
# Merge instead of overwrite: a native
# compaction checkpoint captured on the
# earlier incomplete response is the only
# copy — the continuation won't re-emit
# it. See merge_interim_reasoning_items.
from agent.native_compaction import (
merge_interim_reasoning_items,
)
last_msg[_key] = merge_interim_reasoning_items(
last_msg.get(_key), interim_msg[_key]
)
else:
last_msg[_key] = interim_msg[_key]
else:
messages.append(interim_msg)
agent._emit_interim_assistant_message(interim_msg)
+30
View File
@@ -154,3 +154,33 @@ def is_native_compaction_rejection(error: Any) -> bool:
"""
text = str(error or "").lower()
return "context_management" in text or "compact_threshold" in text
def merge_interim_reasoning_items(
prior_items: Any,
new_items: Any,
) -> List[Dict[str, Any]]:
"""Merge ``codex_reasoning_items`` across Codex incomplete-continuation
dedup, preserving native compaction checkpoints.
The incomplete-retry path updates a visually-duplicate interim assistant
message in place with the newer response's replay payload. A checkpoint
captured on the EARLIER response is a cumulative context carrier the
continuation won't re-emit (the replayed checkpoint keeps the server
render under threshold), so a blind overwrite drops the only copy and the
next request balloons back to full history. Rule: newer items win, but
prior checkpoints are prepended unless the newer payload carries its own.
"""
kept_checkpoints = [
item
for item in (prior_items if isinstance(prior_items, list) else [])
if isinstance(item, dict) and item.get("type") == "compaction"
]
new_list = list(new_items) if isinstance(new_items, list) else []
new_has_checkpoint = any(
isinstance(item, dict) and item.get("type") == "compaction"
for item in new_list
)
if new_has_checkpoint or not kept_checkpoints:
return new_list
return kept_checkpoints + new_list
+166
View File
@@ -0,0 +1,166 @@
"""Tests for stale codex_reasoning_items pruning during compaction (#71058).
Salvaged from PR #71077 (@webtecnica) with two correctness fixes:
the prune boundary is the last USER message (a Codex turn spans multiple
assistant messages whose reasoning items must replay together), and native
compaction checkpoints (type="compaction") are exempt because they carry
already-pruned history, not per-turn reasoning.
"""
from agent.context_compressor import (
_STALE_REPLAY_PRUNE_KEYS,
_prune_stale_reasoning_replay,
)
def _reasoning(item_id="rs_1"):
return {"type": "reasoning", "encrypted_content": "blob-" + item_id, "id": item_id}
def _compaction():
return {"type": "compaction", "encrypted_content": "checkpoint-blob"}
def test_prior_turn_reasoning_items_are_pruned():
messages = [
{"role": "user", "content": "turn 1"},
{"role": "assistant", "content": "a1", "codex_reasoning_items": [_reasoning("rs_a")]},
{"role": "user", "content": "turn 2"},
{"role": "assistant", "content": "a2", "codex_reasoning_items": [_reasoning("rs_b")]},
]
pruned = _prune_stale_reasoning_replay(messages)
assert pruned == 1
assert "codex_reasoning_items" not in messages[1]
# Active turn (after last user message) keeps its items.
assert messages[3]["codex_reasoning_items"] == [_reasoning("rs_b")]
def test_multi_message_active_turn_chain_is_never_pruned():
"""A Codex turn spans assistant+tool_calls -> tool -> assistant; ALL of
the active turn's reasoning items must survive (the #71077 review gap)."""
messages = [
{"role": "user", "content": "old turn"},
{"role": "assistant", "content": "old", "codex_reasoning_items": [_reasoning("rs_old")]},
{"role": "user", "content": "active turn"},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "t", "arguments": "{}"}}],
"codex_reasoning_items": [_reasoning("rs_chain1")],
},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c2", "type": "function", "function": {"name": "t", "arguments": "{}"}}],
"codex_reasoning_items": [_reasoning("rs_chain2")],
},
{"role": "tool", "content": "result", "tool_call_id": "c2"},
{"role": "assistant", "content": "done", "codex_reasoning_items": [_reasoning("rs_final")]},
]
pruned = _prune_stale_reasoning_replay(messages)
assert pruned == 1 # only the old turn
assert "codex_reasoning_items" not in messages[1]
for idx in (3, 5, 7):
assert messages[idx].get("codex_reasoning_items"), f"active-chain msg {idx} lost its items"
def test_native_compaction_checkpoints_survive_pruning():
"""type="compaction" items are cumulative context carriers — they must
survive on stale messages even when reasoning items are stripped."""
messages = [
{"role": "user", "content": "turn 1"},
{
"role": "assistant",
"content": "a1",
"codex_reasoning_items": [_compaction(), _reasoning("rs_a")],
},
{"role": "user", "content": "turn 2"},
{"role": "assistant", "content": "a2"},
]
pruned = _prune_stale_reasoning_replay(messages)
assert pruned == 1
# Reasoning stripped, checkpoint kept.
assert messages[1]["codex_reasoning_items"] == [_compaction()]
def test_checkpoint_only_sidecar_untouched_and_uncounted():
messages = [
{"role": "user", "content": "turn 1"},
{"role": "assistant", "content": "a1", "codex_reasoning_items": [_compaction()]},
{"role": "user", "content": "turn 2"},
{"role": "assistant", "content": "a2"},
]
pruned = _prune_stale_reasoning_replay(messages)
assert pruned == 0
assert messages[1]["codex_reasoning_items"] == [_compaction()]
def test_no_user_boundary_prunes_nothing():
messages = [
{"role": "assistant", "content": "a1", "codex_reasoning_items": [_reasoning("rs_a")]},
{"role": "assistant", "content": "a2", "codex_reasoning_items": [_reasoning("rs_b")]},
]
assert _prune_stale_reasoning_replay(messages) == 0
assert messages[0]["codex_reasoning_items"]
assert messages[1]["codex_reasoning_items"]
def test_non_codex_messages_untouched():
messages = [
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "plain"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "plain2"},
]
assert _prune_stale_reasoning_replay(messages) == 0
assert messages == [
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "plain"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "plain2"},
]
def test_prune_keys_contract():
"""codex_message_items are replayed for prefix-cache continuity and must
NOT be in the prune set; the prune targets reasoning blobs only."""
assert "codex_reasoning_items" in _STALE_REPLAY_PRUNE_KEYS
assert "codex_message_items" not in _STALE_REPLAY_PRUNE_KEYS
class TestInterimMergePreservesCheckpoints:
"""Sibling site: the Codex incomplete-continuation dedup path must not
drop checkpoints when overwriting a visually-duplicate interim message."""
def test_prior_checkpoint_survives_overwrite(self):
from agent.native_compaction import merge_interim_reasoning_items
prior = [_compaction(), _reasoning("rs_old")]
newer = [_reasoning("rs_new")]
merged = merge_interim_reasoning_items(prior, newer)
assert _compaction() in merged
assert _reasoning("rs_new") in merged
assert _reasoning("rs_old") not in merged # newer reasoning wins
def test_newer_checkpoint_wins_outright(self):
from agent.native_compaction import merge_interim_reasoning_items
prior = [{"type": "compaction", "encrypted_content": "old-ckpt"}]
newer = [{"type": "compaction", "encrypted_content": "new-ckpt"}, _reasoning("rs_new")]
merged = merge_interim_reasoning_items(prior, newer)
assert merged == newer
def test_no_prior_checkpoint_is_plain_overwrite(self):
from agent.native_compaction import merge_interim_reasoning_items
assert merge_interim_reasoning_items(
[_reasoning("rs_old")], [_reasoning("rs_new")]
) == [_reasoning("rs_new")]
def test_non_list_inputs_are_safe(self):
from agent.native_compaction import merge_interim_reasoning_items
assert merge_interim_reasoning_items(None, None) == []
assert merge_interim_reasoning_items(None, [_reasoning("r")]) == [_reasoning("r")]
assert merge_interim_reasoning_items([_compaction()], None) == [_compaction()]