# NousResearch/hermes-agent · Context compression summary leaking into visible chat; end-marker changes

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #45153. Verify it addresses your case.
- **Recommended:** read PR #45153 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-45153`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #33256: Context compression summary leaks into user-visible chat output

## Prior & competing PRs

- **#45153: fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (salvages #33346)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/context_compressor.py`, `tests/agent/test_context_compressor.py`
  - fix hunks:
  - `agent/context_compressor.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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})
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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()
    ```
- **#33346: fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (#33256)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `749b7219c`, `#45153`

## Fix status

- Merged to main in PR #45153 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-12`.
- Subsystem: `agent`
