diff --git a/agent/context_compressor.py b/agent/context_compressor.py index b0d92fc011d..6ee5d6cffea 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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 diff --git a/tests/agent/test_compressor_assistant_tail_anchor.py b/tests/agent/test_compressor_assistant_tail_anchor.py index 68d2d9f14eb..a8be6dc3fef 100644 --- a/tests/agent/test_compressor_assistant_tail_anchor.py +++ b/tests/agent/test_compressor_assistant_tail_anchor.py @@ -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 " diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index ee95e8a6e4d..026ee37d951 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -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."""