Context compression summary leaking into visible chat; end-marker changes
Cluster 33256 · agent. Data as of
Nothing open on GitHub. Fix merged in #45153 on 12 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #45153. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #45153. That is separate from the fix event. - Still open
- Nothing open on GitHub.
One issue reports that a context compression summary appears in user-visible chat output. Two pull requests with the same title propose appending an "END OF CONTEXT SUMMARY" marker to standalone summaries; the titles alone do not show whether that marker addresses the reported leak.
Open issues0
Open PRs0
Fixes on record
-
#45153 fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (salvages #33346)
files:
agent/context_compressor.py,tests/agent/test_context_compressor.pyShow the diff
-
agent/context_compressor.py@@ -69,6 +69,16 @@ ) LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" +# Appended to every standalone summary message (and to the merged-into-tail +# prefix) so the model has an unambiguous "summary ends here" boundary. +# Without it, weak models read the verbatim "## Active Task" quote as fresh +# user input (#11475, #14521) or regurgitate an assistant-role summary as +# their own output (#33256). +_SUMMARY_END_MARKER = ( + "--- END OF CONTEXT SUMMARY — " + "respond to the message below, not the summary above ---" +) + # 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 @@ -1616,7 +1626,13 @@ def _strip_summary_prefix(summary: str) -> str: text = (summary or "").strip() for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES): if text.startswith(prefix): - return text[len(prefix):].lstrip() + text = text[len(prefix):].lstrip() + break + # Strip the trailing end marker too — a rehydrated handoff body that + # keeps it would leak the boundary directive into the iterative-update + # summarizer prompt (and the marker is re-appended on insertion anyway). + if text.endswith(_SUMMARY_END_MARKER): + text = text[: -len(_SUMMARY_END_MARKER)].rstrip() return text @classmethod @@ -2193,15 +2209,13 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f # When the summary lands as a standalone role="user" message, # weak models read the verbatim "## Active Task" quote of a past - # user request as fresh input (#11475, #14521). Append the explicit - # end marker — the same one used in the merge-into-tail path — so - # the model has a clear "summary above, not new input" signal. - if not _merge_summary_into_tail and summary_role == "user": - summary = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---" - ) + # user request as fresh input (#11475, #14521). + # When it lands as role="assistant", models may regurgitate the + # summary text as their own output (#33256). In both cases, append + # the explicit end marker so the model has a clear "summary ends + # here, respond to the message below" signal. + if not _merge_summary_into_tail: + summary = summary + "\n\n" + _SUMMARY_END_MARKER if not _merge_summary_into_tail: compressed.append({"role": summary_role, "content": summary}) @@ -2209,11 +2223,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f for i in range(compress_end, n_messages): msg = messages[i].copy() if _merge_summary_into_tail and i == compress_end: - merged_prefix = ( - summary - + "\n\n--- END OF CONTEXT SUMMARY — " - "respond to the message below, not the summary above ---\n\n" - ) + merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n" msg["content"] = _append_text_to_content( msg.get("content"), merged_prefix, -
tests/agent/test_context_compressor.py@@ -1252,6 +1252,48 @@ def test_user_role_summary_carries_end_marker(self): "respond to the message below, not the summary above ---" ) + def test_assistant_role_summary_carries_end_marker(self): + """When the summary lands as standalone role='assistant' (head ends + with user), the message body must include the explicit + '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may + regurgitate the summary text as their own output (#33256). + """ + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened" + mock_client.chat.completions.create.return_value = mock_response + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + # head_last=user → summary_role="assistant" (same setup as + # test_summary_role_avoids_consecutive_user_when_head_ends_with_user). + # With min_tail=3, tail = last 3 messages (indices 5-7). + # head_last=user, tail_first=user → the assistant-role summary does + # not collide with either neighbor and should be inserted standalone. + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "msg 1"}, + {"role": "user", "content": "msg 2"}, # last head — user + {"role": "assistant", "content": "msg 3"}, + {"role": "user", "content": "msg 4"}, + {"role": "user", "content": "msg 5"}, + {"role": "assistant", "content": "msg 6"}, + {"role": "user", "content": "msg 7"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + summary_msg = next( + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ) + assert summary_msg["role"] == "assistant" + assert "END OF CONTEXT SUMMARY" in summary_msg["content"] + assert summary_msg["content"].rstrip().endswith( + "respond to the message below, not the summary above ---" + ) + def test_summary_role_avoids_consecutive_user_messages(self): """Summary role should alternate with the last head message to avoid consecutive same-role messages.""" mock_client = MagicMock()
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#33346 fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (#33256) · merged via another change →
749b721,#45153
Closed issues · 1
- Closed#33256Context compression summary leaks into user-visible chat output
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #45153
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 12 Jun 2026