fix(agent): protect batch-compaction markers from micro supersede/defrag

Phase 2 review findings on the salvage branch:

C1 (critical): batch and micro summary markers share
COMPRESSED_SUMMARY_METADATA_KEY, and compress() never reset micro state.
After micro absorbed exchanges 1..k, a batch compaction summarizing
1..m (m>k) could fire; the next micro pass's supersede then dropped the
batch marker (whose content the stale rolling summary does NOT contain)
and archive_and_compact immediately made the loss durable. Defrag had
the same hazard: it rewrote "the newest marker" even if that was a
batch marker. Empirically confirmed with a probe (batch marker content
destroyed in one pass).

Fix, three parts:
- Micro-created markers now carry MICRO_COMPACT_MARKER_KEY; supersede
  and defrag only ever touch micro-tagged markers. Rehydration in
  _resolve_compact_cursor tags the marker it absorbs (containment
  proof), which safely covers adopting a batch marker as the new
  rolling base after a reset.
- compress() success path resets micro rolling summary/cursor state so
  a stale summary can never claim cumulativeness over a batch marker.
- Regression tests for both directions plus the reset.

W4: _splice_micro_compact_result no longer strips _db_persisted stamps
from surviving messages. Micro archives in place under the SAME session
id (unlike batch's child-session rotation, #57491), so surviving stamps
are accurate; stripping them meant an archive_and_compact failure left
every previously-persisted message unstamped and the next append-only
flush re-inserted them all as duplicate active rows.

W5: finalize_turn micro gate now checks agent._persist_disabled —
persistence-isolated fork agents (background review) must not burn an
aux call per review turn, and must never archive_and_compact the
canonical session rows if their compressor ever gains a DB binding.

W1: _serialize_one_exchange now delegates to _serialize_for_summary
(was a ~70-line near-verbatim copy; one serializer, one place to fix).

S4: _find_one_exchange boundary guard rejects only assistant/tool
boundaries (the actual alternation hazard) instead of requiring user —
a stray mid-list system/injected message can no longer wedge the
cursor forever.

5 new regression tests; 38 micro/prune tests, 400 compression-suite
tests, 61 finalize/persist tests pass; ruff clean.
This commit is contained in:
kshitijk4poor 2026-07-31 17:37:44 +05:30 committed by kshitij
parent c696a5fd9c
commit 53559aaf86
3 changed files with 209 additions and 83 deletions

View file

@ -137,6 +137,12 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY = "_compressed_summary_has_user_turn"
# Distinguishes rolling micro-compaction markers from batch-compaction
# markers (both carry COMPRESSED_SUMMARY_METADATA_KEY so resume/handoff
# treat them alike). Supersede/defrag/rehydration must only ever touch
# micro markers: a batch marker's content is NOT contained in the micro
# rolling summary, so dropping or rewriting one destroys history.
MICRO_COMPACT_MARKER_KEY = "_micro_compact_marker"
_DB_PERSISTED_MARKER = "_db_persisted"
_NO_USER_TASK_SENTINEL = "None. This session contains no user-authored turns."
@ -5052,6 +5058,14 @@ This compaction should PRIORITISE preserving all information related to the focu
)
if recovered:
self._micro_compact_rolling_summary = recovered
# Rehydration is containment proof: this marker's text now
# lives inside the rolling summary, so it becomes
# supersede/defrag-eligible. This also covers a BATCH
# marker adopted as the rolling base after a batch
# compaction reset — safe precisely because we just
# absorbed its content. Markers whose content we did NOT
# absorb never get the key and are never dropped.
messages[last_summary_idx][MICRO_COMPACT_MARKER_KEY] = True
logger.info(
"Micro-compaction: recovered rolling summary from "
"transcript (%d chars)", len(recovered),
@ -5132,17 +5146,22 @@ This compaction should PRIORITISE preserving all information related to the focu
if idx <= exchange_start:
return None
# Splice-boundary guard: the message right after the exchange must be
# a user turn (or a summary marker, which stands in for one
# structurally). If the walk stopped because it ran into *tail_start*
# mid-turn, splicing here would leave the assistant-role marker
# adjacent to the turn's remaining assistant/tool messages — invalid
# alternation. Skip this pass; the tail recedes as the conversation
# grows and the turn becomes absorbable later.
# Splice-boundary guard: the message right after the exchange must
# close the turn. If the walk stopped because it ran into
# *tail_start* mid-turn (boundary is assistant or tool — including
# an assistant-role summary marker), splicing here would leave the
# assistant-role marker adjacent to the turn's remaining
# assistant/tool messages — invalid alternation. Skip this pass; the
# tail recedes as the conversation grows and the turn becomes
# absorbable later. Any other boundary role (user, or a stray
# system/injected message) is a safe splice point — the marker is
# assistant-role, so no same-role adjacency is possible — and
# accepting them keeps one odd message from wedging the cursor
# forever.
if idx >= n:
return None
boundary = messages[idx]
if not isinstance(boundary, dict) or boundary.get("role") != "user":
if not isinstance(boundary, dict) or boundary.get("role") in ("assistant", "tool"):
return None
return (exchange_start, idx)
@ -5154,72 +5173,11 @@ This compaction should PRIORITISE preserving all information related to the focu
) -> str:
"""Serialize a single exchange for the micro-summarizer.
Uses the same content-max truncation and redaction as the batch
``_serialize_for_summary`` method, but scoped to one exchange.
Delegates to the batch path's ``_serialize_for_summary`` (same
truncation, redaction, think-block stripping, and media labeling),
scoped to one exchange one serializer, one place to fix.
"""
from agent.agent_runtime_helpers import strip_think_blocks
parts = []
for msg in messages[start:end]:
role = msg.get("role", "unknown")
content = msg.get("content")
if isinstance(content, list):
text_parts: list[str] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
text_parts.append(part.get("text", ""))
elif ptype in {"image", "image_url", "input_image"}:
text_parts.append(_image_part_label(part))
else:
text_parts.append(f"[{ptype or 'attachment'}]")
elif isinstance(part, str):
text_parts.append(part)
content = "\n".join(text_parts)
content = _redact_compaction_text(content or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
if role == "assistant" and content:
content = strip_think_blocks(None, content)
if role == "tool":
tool_id = msg.get("tool_call_id", "")
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
parts.append(f"[TOOL RESULT {tool_id}]: {content}")
continue
if role == "assistant":
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
tool_calls = msg.get("tool_calls", [])
if tool_calls:
tc_parts = []
for tc in tool_calls:
if isinstance(tc, dict):
fn = tc.get("function", {})
name = fn.get("name", "?")
args = _redact_compaction_text(fn.get("arguments", ""))
if len(args) > self._TOOL_ARGS_MAX:
args = args[:self._TOOL_ARGS_HEAD] + "..."
tc_parts.append(f" {name}({args})")
else:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", "?") if fn else "?"
tc_parts.append(f" {name}(...)")
content += "\n[Tool calls:\n" + "\n".join(tc_parts) + "\n]"
parts.append(f"[ASSISTANT]: {content}")
continue
if role == "user":
if len(content) > self._CONTENT_MAX:
content = content[:self._CONTENT_HEAD] + "\n...[truncated]...\n" + content[-self._CONTENT_TAIL:]
parts.append(f"[USER]: {content}")
continue
parts.append(f"[{role.upper()}]: {content}")
return "\n---\n".join(parts)
return self._serialize_for_summary(messages[start:end])
def _build_micro_summary_prompt(
self,
@ -5349,11 +5307,17 @@ This compaction should PRIORITISE preserving all information related to the focu
self._micro_compact_rolling_summary = old_summary
return False
self._micro_compact_rolling_summary = fresh_summary
# Rewrite the newest marker's content in place so the transcript and
# the in-memory summary stay in step (resume rehydrates from it).
# Rewrite the newest MICRO marker's content in place so the transcript
# and the in-memory summary stay in step (resume rehydrates from it).
# Scoped to micro-tagged markers: rewriting a batch-compaction marker
# would overwrite history the rolling summary does not contain.
for idx in range(len(messages) - 1, -1, -1):
entry = messages[idx]
if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY):
if (
isinstance(entry, dict)
and entry.get(COMPRESSED_SUMMARY_METADATA_KEY)
and entry.get(MICRO_COMPACT_MARKER_KEY)
):
entry["content"] = self._render_micro_marker_content(fresh_summary)
# Content changed after a possible flush — clear the persisted
# stamp so the DB sync/flush rewrites the row.
@ -5700,6 +5664,10 @@ This compaction should PRIORITISE preserving all information related to the focu
"role": "assistant",
"content": self._render_micro_marker_content(summary_text),
COMPRESSED_SUMMARY_METADATA_KEY: True,
# Micro-created marker: eligible for supersede/defrag rewrites.
# Batch markers never carry this key and are never touched —
# their content is not contained in the rolling summary.
MICRO_COMPACT_MARKER_KEY: True,
# Honest provenance (#64650): this marker absorbs only
# assistant/tool content — user turns are never micro-compacted,
# so they remain in the transcript and _transcript_has_real_user_turn
@ -5715,22 +5683,39 @@ This compaction should PRIORITISE preserving all information related to the focu
# its own prefix/heading/end-marker scaffolding — so the transcript
# grows with every turn instead of shrinking, which defeats the point.
# Keep only the newest marker.
# Only drop earlier markers when this one demonstrably contains them:
# the rolling summary must have been non-empty going into this pass.
# A pass that started from nothing (a resume that could not rehydrate)
# produces a marker covering one exchange, and dropping the previous
# marker would throw away the entire compacted history.
# Two containment gates before dropping an earlier marker:
# 1. supersede (the rolling summary was non-empty going into this
# pass) — a pass that started from nothing (a resume that could
# not rehydrate) covers one exchange, and dropping the previous
# marker would throw away the entire compacted history.
# 2. MICRO_COMPACT_MARKER_KEY on the candidate — only markers whose
# text is provably inside the rolling summary (created by our own
# splice, or rehydrated into the summary by
# _resolve_compact_cursor) carry it. A batch-compaction marker
# that landed after our last pass holds MORE history than the
# stale rolling summary; dropping it would destroy that history.
if supersede:
marker_idxs = [
i for i, m in enumerate(result)
if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
if isinstance(m, dict)
and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
and m.get(MICRO_COMPACT_MARKER_KEY)
]
if len(marker_idxs) > 1:
superseded = set(marker_idxs[:-1])
result = [m for i, m in enumerate(result) if i not in superseded]
result = self._merge_adjacent_user_turns(result)
_strip_persistence_markers(result)
# NOTE: deliberately NO _strip_persistence_markers here. The batch
# path strips because compress() copies head/tail into a rotated
# child session (#57491); micro-compaction archives in place under
# the SAME session id, and the surviving dicts' _db_persisted stamps
# are accurate. Stripping them meant an archive_and_compact failure
# left every previously-persisted message unstamped, and the next
# append-only flush re-inserted them as duplicate active rows on top
# of the still-active originals. _sync_micro_compact_to_db re-stamps
# everything after a SUCCESSFUL archive; on failure the old stamps
# keep the flush idempotent (only the new marker row is appended).
return result
@staticmethod
@ -6468,6 +6453,19 @@ This compaction should PRIORITISE preserving all information related to the focu
_strip_persistence_markers(compressed)
self._last_compression_made_progress = True
# Batch compaction invalidates micro-compaction state: the batch
# marker now holds MORE history than the in-memory rolling summary
# (it summarized everything in the window, including exchanges micro
# never absorbed). Keeping the stale summary would let the next micro
# pass supersede-drop or defrag-rewrite content it does not contain.
# Reset instead; the next micro pass rehydrates from the batch marker
# via _resolve_compact_cursor, which re-tags it as micro-eligible
# only after absorbing its content into the rolling summary.
self._micro_compact_rolling_summary = ""
self._micro_compact_cursor = 0
self._micro_compact_consecutive_failures = 0
self._micro_compact_last_failure_cursor = -1
return compressed

View file

@ -368,6 +368,13 @@ def finalize_turn(
and getattr(_compressor, '_micro_compact_enabled', False) is True
and callable(getattr(_compressor, '_micro_compact', None))
and final_response
# Persistence-isolated agents (background review fork)
# must not micro-compact: the pass burns a real aux-LLM
# call on a throwaway replay transcript, and if the
# compressor ever holds a session_db binding it would
# archive_and_compact the CANONICAL session rows — the
# exact write class _persist_disabled exists to stop.
and not getattr(agent, "_persist_disabled", False)
):
_before = len(messages)
_compacted = _compressor._micro_compact(messages)

View file

@ -631,3 +631,124 @@ class TestMicroCompaction:
marker = _summary_markers(result)[0]
assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False
def test_supersede_never_drops_a_batch_compaction_marker(self):
"""A batch marker holds history the rolling summary does NOT contain.
Sequence: micro absorbs some exchanges (rolling summary = those k
exchanges only), then batch compaction fires and its marker
summarizes MORE (exchanges 1..m). The next micro pass's supersede
must not treat the batch marker as redundant dropping it destroys
everything batch summarized beyond exchange k. Only micro-tagged
markers (whose text is provably inside the rolling summary) may be
superseded.
"""
cc = _compressor(summary="MICRO SUMMARY (exchanges 1..k only)")
msgs = _conversation(exchanges=8)
msgs = cc._micro_compact(msgs)
assert cc._micro_compact_rolling_summary
# Simulate a batch-compaction marker replacing the middle (batch
# markers carry the shared metadata key but NOT the micro tag).
batch_marker = {
"role": "user",
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
COMPRESSED_SUMMARY_METADATA_KEY: True,
}
micro_idx = next(
i for i, m in enumerate(msgs)
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
)
msgs = msgs[:micro_idx] + [batch_marker] + msgs[micro_idx + 3:]
out = cc._micro_compact(msgs)
assert any(
"CRITICAL HISTORY" in str(m.get("content")) for m in out
), "batch-compaction summary destroyed by micro supersede"
def test_defrag_never_rewrites_a_batch_compaction_marker(self):
"""Defrag rewrites only micro-tagged markers, never batch markers."""
cc = _compressor(summary="DEFRAGGED")
msgs = [{"role": "system", "content": "sys"}]
msgs.append({
"role": "user",
"content": "[batch summary] CRITICAL HISTORY: exchanges 1..m",
COMPRESSED_SUMMARY_METADATA_KEY: True,
})
for i in range(6):
msgs.append({"role": "user", "content": f"q{i}"})
msgs.append({"role": "assistant", "content": f"a{i} " + "z" * 400})
cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag
result = cc._micro_compact(list(msgs))
batch = [m for m in result if "CRITICAL HISTORY" in str(m.get("content"))]
assert batch, "batch marker content overwritten by defrag"
def test_batch_compress_resets_micro_state(self):
"""compress() success path invalidates the stale rolling summary.
Without the reset, the in-memory micro summary (exchanges 1..k)
outlives a batch compaction whose marker covers 1..m the next
micro pass would then treat its stale summary as cumulative.
"""
cc = _compressor()
msgs = _conversation(exchanges=8)
msgs = cc._micro_compact(msgs)
assert cc._micro_compact_rolling_summary
assert cc._micro_compact_cursor > 0
cc.compress(msgs, force=True)
assert cc._micro_compact_rolling_summary == ""
assert cc._micro_compact_cursor == 0
def test_persist_disabled_agent_never_micro_compacts(self):
"""finalize_turn must skip micro-compaction on isolated fork agents.
The background-review fork sets _persist_disabled=True; running a
pass there burns an aux-LLM call on a throwaway replay transcript
and, if the compressor ever holds a DB binding, would
archive_and_compact the CANONICAL session rows.
"""
import inspect
from agent import turn_finalizer
src = inspect.getsource(turn_finalizer.finalize_turn)
micro_block = src.split("Post-turn micro-compaction", 1)[1]
# Scope to the micro block only: stop at the persist call that follows.
micro_block = micro_block.split("agent._persist_session", 1)[0]
assert "_persist_disabled" in micro_block, (
"micro-compaction gate must check agent._persist_disabled"
)
def test_splice_preserves_db_persisted_stamps(self):
"""Surviving messages keep their _db_persisted stamps through a splice.
Micro-compaction archives in place under the SAME session id, so the
stamps on untouched messages stay accurate. Stripping them (as the
batch path does for its child-session rotation) meant an
archive_and_compact failure left every previously-persisted message
unstamped and the next append-only flush re-inserted them all as
duplicate active rows.
"""
from agent.context_compressor import _DB_PERSISTED_MARKER
cc = _compressor()
messages = _conversation(exchanges=8)
for m in messages:
m[_DB_PERSISTED_MARKER] = True
# No DB bound -> _sync_micro_compact_to_db no-ops (the failure shape).
result = cc._micro_compact(messages)
unstamped = [
m for m in result
if not m.get(_DB_PERSISTED_MARKER)
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert not unstamped, (
"splice must not strip _db_persisted from surviving messages"
)