{"repo":"NousResearch/hermes-agent","public_id":"11996","display_label":"Decay head-message protection across compression cycles (protect_first_n)","summary":"All three members concern early messages protected by protect_first_n persisting across repeated compression cycles, described as 'fossilization'. One is a bug report and two are fix proposals that decay the head protection over cycles; their actual content and outcomes are not shown in the titles.","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":50001,"merged_at":"2026-06-21T07:06:59+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-21T07:06:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7133,"pr_number":50001,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-21T07:06:59+00:00"},{"issue_number":11996,"pr_number":50001,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-21T07:06:59+00:00"}],"members":[{"kind":"issue","number":11996,"title":"[Bug]: protect_first_n causes head message fossilization across compressions — old user messages become immortal","url":null,"state":"closed"},{"kind":"pr","number":12092,"title":"fix(compressor): decay head protection across compression cycles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50001","number":50001}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50001,"title":"fix(compression): decay protect_first_n so early turns don't fossilize (#11996)","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":["@@ -1849,6 +1849,23 @@ def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> i\n             idx += 1\n         return idx\n \n+    def _effective_protect_first_n(self) -> int:\n+        \"\"\"``protect_first_n`` decayed across compression cycles.\n+\n+        ``protect_first_n`` keeps the first N non-system messages verbatim so\n+        the original task framing survives the FIRST compaction. But applying\n+        it on every subsequent pass fossilizes those early turns — they're\n+        re-copied into each child session and never summarized away, so old\n+        user messages become immortal and grow the head unboundedly across a\n+        long session (#11996). Once the session has been compressed at least\n+        once, the early turns are already captured in the handoff summary, so\n+        there's no need to keep re-protecting them: decay to 0 (the system\n+        prompt is still always protected separately by _protect_head_size).\n+        \"\"\"\n+        if self.compression_count >= 1 or self._previous_summary:\n+            return 0\n+        return self.protect_first_n\n+\n     def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:\n         \"\"\"Total count of head messages to protect.\n ","@@ -1860,14 +1877,19 @@ def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:\n         the ``messages`` list (e.g. the gateway ``/compress`` handler\n         strips it before calling compress()).\n \n-        Examples:\n+        The ``protect_first_n`` portion DECAYS after the first compression\n+        (see _effective_protect_first_n) so early user turns don't fossilize\n+        across repeated compactions (#11996).\n+\n+        Examples (first compaction):\n           protect_first_n=0 → system prompt only (or nothing if no system msg)\n           protect_first_n=3 → system + first 3 non-system messages\n+        After the first compaction: system prompt only.\n         \"\"\"\n         head = 0\n         if messages and messages[0].get(\"role\") == \"system\":\n             head = 1\n-        return head + self.protect_first_n\n+        return head + self._effective_protect_first_n()\n \n     def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:\n         \"\"\"Pull a compress-end boundary backward to avoid splitting a"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -225,6 +225,39 @@ def test_protects_first_and_last(self, compressor):\n         # original content is present in either case.\n         assert msgs[-2][\"content\"] in result[-2][\"content\"]\n \n+    def test_protect_first_n_decays_after_first_compression(self):\n+        \"\"\"Regression for #11996: protect_first_n must protect early turns on\n+        the FIRST compaction but DECAY afterwards, so the same early user\n+        messages don't get re-copied verbatim into every child session and\n+        fossilize (grow immortal) across a long, repeatedly-compressed\n+        session. The system prompt is always protected separately.\"\"\"\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(model=\"test\", quiet_mode=True, protect_first_n=3)\n+\n+        msgs = [{\"role\": \"system\", \"content\": \"sys\"}] + [\n+            {\"role\": \"user\" if i % 2 == 0 else \"assistant\", \"content\": f\"m{i}\"}\n+            for i in range(10)\n+        ]\n+\n+        # First compaction: protect system + first 3 non-system.\n+        assert c.compression_count == 0\n+        assert c._effective_protect_first_n() == 3\n+        assert c._protect_head_size(msgs) == 1 + 3\n+\n+        # Simulate having compressed once — early turns now live in the summary.\n+        c.compression_count = 1\n+        assert c._effective_protect_first_n() == 0\n+        assert c._protect_head_size(msgs) == 1  # system prompt only\n+\n+    def test_protect_first_n_decays_when_previous_summary_exists(self):\n+        \"\"\"Even if compression_count was reset, an existing handoff summary\n+        means the early turns are already captured — decay still applies.\"\"\"\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(model=\"test\", quiet_mode=True, protect_first_n=3)\n+        c.compression_count = 0\n+        c._previous_summary = \"[CONTEXT SUMMARY]: earlier work\"\n+        assert c._effective_protect_first_n() == 0\n+\n \n class TestGenerateSummaryNoneContent:\n     \"\"\"Regression: content=None (from tool-call-only assistant messages) must not crash.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}