fix(compaction): detect and strip merge-into-tail summaries past the delimiter

Follow-up to the END-MARKER reorder: moving the summary prefix after the
[PRIOR CONTEXT] wrapper meant _is_context_summary_content (prefix-at-start)
no longer recognized a merged-tail summary. That silently broke three
consumers — the last-real-user anchor (would pick the merged summary as a
real user turn, causing active-task loss), the carry-forward summary find,
and the auto-focus skip. _strip_summary_prefix would also carry the wrapper
+ stale tail content forward as the next summary body.

Extract the two delimiter strings into _MERGED_PRIOR_CONTEXT_HEADER /
_MERGED_SUMMARY_DELIMITER constants (writer + detector stay in sync), teach
_is_context_summary_content and _strip_summary_prefix to look past the
delimiter, and add a regression test. Standalone summaries unchanged.
This commit is contained in:
kshitijk4poor 2026-07-01 18:08:45 +05:30 committed by kshitij
parent a1a8a967e1
commit b795a45b8d
3 changed files with 78 additions and 11 deletions

View file

@ -95,6 +95,15 @@ _SUMMARY_END_MARKER = (
"respond to the message below, not the summary above ---"
)
# When the summary must be merged into the first tail message (the alternation
# corner case where a standalone summary role would collide with both head and
# tail), the tail message's own prior content is preserved BEFORE the summary,
# wrapped in these delimiters so the model doesn't read it as a fresh message.
# The summary prefix therefore lands AFTER _MERGED_SUMMARY_DELIMITER rather than
# at the start of the message, so _is_context_summary_content must look past it.
_MERGED_PRIOR_CONTEXT_HEADER = "[PRIOR CONTEXT — for reference only; not a new message]"
_MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
@ -2007,6 +2016,13 @@ This compaction should PRIORITISE preserving all information related to the focu
stale directive it carried stays embedded in the body.
"""
text = (summary or "").strip()
# Merge-into-tail summaries wrap prior tail content before the summary
# body. Drop everything up to and including the delimiter so only the
# real summary body is carried forward on re-compaction — otherwise the
# [PRIOR CONTEXT] header and stale tail content leak into the next
# summarizer prompt.
if _MERGED_SUMMARY_DELIMITER in text:
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
@ -2027,6 +2043,13 @@ This compaction should PRIORITISE preserving all information related to the focu
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
text = _content_text_for_contains(content).lstrip()
# Merge-into-tail summaries wrap prior tail content before the summary,
# so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than
# at the start. Detect the summary in that region too, otherwise callers
# (auto-focus skip, carry-forward summary find, last-real-user anchor)
# mistake a merged summary message for a real user turn.
if _MERGED_SUMMARY_DELIMITER in text:
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@ -2898,13 +2921,13 @@ This compaction should PRIORITISE preserving all information related to the focu
# before the summary.
old_content = msg.get("content", "")
suffix = (
"\n\n[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]\n\n"
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
+ summary + "\n\n"
+ _SUMMARY_END_MARKER
)
msg["content"] = _append_text_to_content(
_append_text_to_content(old_content, suffix, prepend=False),
"[PRIOR CONTEXT — for reference only; not a new message]\n",
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
prepend=True,
)
# Mark the merged message so frontends can identify it as

View file

@ -357,7 +357,10 @@ class TestCompactionRollupReproduction:
in ``web/src/pages/SessionsPage.tsx``)."""
def test_compress_keeps_visible_reply_text(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
from agent.context_compressor import (
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
)
c = compressor
c.tail_token_budget = 10
# ``_generate_summary`` normally wraps the LLM body in
@ -392,10 +395,12 @@ class TestCompactionRollupReproduction:
return_value=_mocked,
):
result = c.compress(messages, current_tokens=90_000)
# 1. A summary message exists (compression actually ran).
# 1. A summary message exists (compression actually ran). Detect via
# the canonical metadata key rather than a content prefix: merge-into-
# tail summaries wrap prior content before the summary, so the prefix
# is no longer at the start of the message content (#56372).
assert any(
isinstance(m.get("content"), str)
and m["content"].startswith(SUMMARY_PREFIX)
m.get(COMPRESSED_SUMMARY_METADATA_KEY)
for m in result
), "compress() did not insert a summary message"
# 2. The visible reply text must survive somewhere — either
@ -419,7 +424,10 @@ class TestCompactionRollupReproduction:
as its OWN assistant message not merged with anything.
This is the common case; the merge-into-tail path is the
edge case for double-collision."""
from agent.context_compressor import SUMMARY_PREFIX
from agent.context_compressor import (
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
)
c = compressor
c.tail_token_budget = 10
_mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
@ -449,11 +457,11 @@ class TestCompactionRollupReproduction:
return_value=_mocked,
):
result = c.compress(messages, current_tokens=90_000)
# Standalone summary present:
# Summary present (detect via the canonical metadata key — merge-into-
# tail summaries no longer start with SUMMARY_PREFIX after #56372):
summary_rows = [
m for m in result
if isinstance(m.get("content"), str)
and m["content"].startswith(SUMMARY_PREFIX)
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert len(summary_rows) == 1
# Visible reply as its OWN distinct assistant message
@ -463,7 +471,7 @@ class TestCompactionRollupReproduction:
if m.get("role") == "assistant"
and isinstance(m.get("content"), str)
and "THE VISIBLE REPLY THE USER JUST READ" in m["content"]
and not m["content"].startswith(SUMMARY_PREFIX)
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert len(reply_rows) == 1, (
"REGRESSION (#29824): expected exactly one standalone "

View file

@ -2009,6 +2009,42 @@ class TestCompressWithClient:
assert text.index("SUMMARY_BODY") < text.index(end)
assert text.rstrip().endswith(end)
def test_merged_tail_summary_still_detected_and_stripped(self):
"""Regression for #56372 salvage: the merge-into-tail reorder moves the
summary prefix AFTER the [PRIOR CONTEXT] wrapper, so content-prefix
detection (_is_context_summary_content) and body extraction
(_strip_summary_prefix) must look past the delimiter. Otherwise a merged
summary is mistaken for a real user turn (breaking the last-real-user
anchor and carry-forward summary find) and the wrapper + stale tail
content leaks into the next summarizer prompt.
"""
from agent.context_compressor import (
SUMMARY_PREFIX,
_SUMMARY_END_MARKER,
_MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER,
)
merged = (
_MERGED_PRIOR_CONTEXT_HEADER + "\n"
"old tail content here\n\n"
+ _MERGED_SUMMARY_DELIMITER + "\n\n"
+ SUMMARY_PREFIX + "\nTHE_SUMMARY_BODY\n\n"
+ _SUMMARY_END_MARKER
)
# Detected as a summary despite the prefix not being at the start.
assert ContextCompressor._is_context_summary_content(merged) is True
# Stripping yields only the real summary body — no wrapper, no stale
# tail content, no prefix, no end marker.
body = ContextCompressor._strip_summary_prefix(merged)
assert body == "THE_SUMMARY_BODY"
# Standalone (non-merged) summaries still work unchanged.
standalone = SUMMARY_PREFIX + "\nSTANDALONE_BODY\n\n" + _SUMMARY_END_MARKER
assert ContextCompressor._is_context_summary_content(standalone) is True
assert ContextCompressor._strip_summary_prefix(standalone) == "STANDALONE_BODY"
def test_double_collision_user_head_assistant_tail(self):
"""Reverse double collision: head ends with 'user', tail starts with 'assistant'.
summary='assistant' collides with tail, 'user' collides with head merge."""