{"repo":"NousResearch/hermes-agent","public_id":"33256","display_label":"Context compression summary leaking into visible chat; end-marker changes","summary":"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.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45153,"merged_at":"2026-06-12T22:05:01+00:00"},"resolution":{"state":"resolved","by":"pr-45153"},"freshness":{"state":"verified","last_verified_at":"2026-06-12T22:05:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":33256,"pr_number":45153,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-12T22:05:01+00:00"},{"issue_number":42768,"pr_number":45153,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-12T22:05:01+00:00"}],"members":[{"kind":"issue","number":33256,"title":"Context compression summary leaks into user-visible chat output","url":null,"state":"closed"},{"kind":"pr","number":33346,"title":"fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (#33256)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"749b7219c","number":0},{"kind":"pr","value":"#45153","number":45153}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":45153,"title":"fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (salvages #33346)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -69,6 +69,16 @@\n )\n LEGACY_SUMMARY_PREFIX = \"[CONTEXT SUMMARY]:\"\n \n+# Appended to every standalone summary message (and to the merged-into-tail\n+# prefix) so the model has an unambiguous \"summary ends here\" boundary.\n+# Without it, weak models read the verbatim \"## Active Task\" quote as fresh\n+# user input (#11475, #14521) or regurgitate an assistant-role summary as\n+# their own output (#33256).\n+_SUMMARY_END_MARKER = (\n+    \"--- END OF CONTEXT SUMMARY — \"\n+    \"respond to the message below, not the summary above ---\"\n+)\n+\n # Handoff prefixes that shipped in earlier releases. A summary persisted under\n # one of these can be inherited into a resumed lineage (#35344); when it is\n # 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:\n         text = (summary or \"\").strip()\n         for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):\n             if text.startswith(prefix):\n-                return text[len(prefix):].lstrip()\n+                text = text[len(prefix):].lstrip()\n+                break\n+        # Strip the trailing end marker too — a rehydrated handoff body that\n+        # keeps it would leak the boundary directive into the iterative-update\n+        # summarizer prompt (and the marker is re-appended on insertion anyway).\n+        if text.endswith(_SUMMARY_END_MARKER):\n+            text = text[: -len(_SUMMARY_END_MARKER)].rstrip()\n         return text\n \n     @classmethod","@@ -2193,15 +2209,13 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n \n         # When the summary lands as a standalone role=\"user\" message,\n         # weak models read the verbatim \"## Active Task\" quote of a past\n-        # user request as fresh input (#11475, #14521). Append the explicit\n-        # end marker — the same one used in the merge-into-tail path — so\n-        # the model has a clear \"summary above, not new input\" signal.\n-        if not _merge_summary_into_tail and summary_role == \"user\":\n-            summary = (\n-                summary\n-                + \"\\n\\n--- END OF CONTEXT SUMMARY — \"\n-                \"respond to the message below, not the summary above ---\"\n-            )\n+        # user request as fresh input (#11475, #14521).\n+        # When it lands as role=\"assistant\", models may regurgitate the\n+        # summary text as their own output (#33256). In both cases, append\n+        # the explicit end marker so the model has a clear \"summary ends\n+        # here, respond to the message below\" signal.\n+        if not _merge_summary_into_tail:\n+            summary = summary + \"\\n\\n\" + _SUMMARY_END_MARKER\n \n         if not _merge_summary_into_tail:\n             compressed.append({\"role\": summary_role, \"content\": summary})","@@ -2209,11 +2223,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         for i in range(compress_end, n_messages):\n             msg = messages[i].copy()\n             if _merge_summary_into_tail and i == compress_end:\n-                merged_prefix = (\n-                    summary\n-                    + \"\\n\\n--- END OF CONTEXT SUMMARY — \"\n-                    \"respond to the message below, not the summary above ---\\n\\n\"\n-                )\n+                merged_prefix = summary + \"\\n\\n\" + _SUMMARY_END_MARKER + \"\\n\\n\"\n                 msg[\"content\"] = _append_text_to_content(\n                     msg.get(\"content\"),\n                     merged_prefix,"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -1252,6 +1252,48 @@ def test_user_role_summary_carries_end_marker(self):\n             \"respond to the message below, not the summary above ---\"\n         )\n \n+    def test_assistant_role_summary_carries_end_marker(self):\n+        \"\"\"When the summary lands as standalone role='assistant' (head ends\n+        with user), the message body must include the explicit\n+        '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may\n+        regurgitate the summary text as their own output (#33256).\n+        \"\"\"\n+        mock_client = MagicMock()\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"[CONTEXT SUMMARY]: stuff happened\"\n+        mock_client.chat.completions.create.return_value = mock_response\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2)\n+\n+        # head_last=user → summary_role=\"assistant\" (same setup as\n+        # test_summary_role_avoids_consecutive_user_when_head_ends_with_user).\n+        # With min_tail=3, tail = last 3 messages (indices 5-7).\n+        # head_last=user, tail_first=user → the assistant-role summary does\n+        # not collide with either neighbor and should be inserted standalone.\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"system prompt\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"user\", \"content\": \"msg 2\"},  # last head — user\n+            {\"role\": \"assistant\", \"content\": \"msg 3\"},\n+            {\"role\": \"user\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+            {\"role\": \"user\", \"content\": \"msg 7\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        summary_msg = next(\n+            m for m in result if (m.get(\"content\") or \"\").startswith(SUMMARY_PREFIX)\n+        )\n+        assert summary_msg[\"role\"] == \"assistant\"\n+        assert \"END OF CONTEXT SUMMARY\" in summary_msg[\"content\"]\n+        assert summary_msg[\"content\"].rstrip().endswith(\n+            \"respond to the message below, not the summary above ---\"\n+        )\n+\n     def test_summary_role_avoids_consecutive_user_messages(self):\n         \"\"\"Summary role should alternate with the last head message to avoid consecutive same-role messages.\"\"\"\n         mock_client = MagicMock()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}