NousResearch/hermes-agent

Decay head-message protection across compression cycles (protect_first_n)

Cluster 11996 · agent. Data as of

Nothing open on GitHub. Fix merged in #50001 on 21 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #50001. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50001 fix(compression): decay protect_first_n so early turns don't fossilize (#11996)

    merged to main

    files: agent/context_compressor.py, tests/agent/test_context_compressor.py

    Show the diff
    • agent/context_compressor.py
      @@ -1849,6 +1849,23 @@ def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> i
                   idx += 1
               return idx
       
      +    def _effective_protect_first_n(self) -> int:
      +        """``protect_first_n`` decayed across compression cycles.
      +
      +        ``protect_first_n`` keeps the first N non-system messages verbatim so
      +        the original task framing survives the FIRST compaction. But applying
      +        it on every subsequent pass fossilizes those early turns — they're
      +        re-copied into each child session and never summarized away, so old
      +        user messages become immortal and grow the head unboundedly across a
      +        long session (#11996). Once the session has been compressed at least
      +        once, the early turns are already captured in the handoff summary, so
      +        there's no need to keep re-protecting them: decay to 0 (the system
      +        prompt is still always protected separately by _protect_head_size).
      +        """
      +        if self.compression_count >= 1 or self._previous_summary:
      +            return 0
      +        return self.protect_first_n
      +
           def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
               """Total count of head messages to protect.
       
      @@ -1860,14 +1877,19 @@ def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
               the ``messages`` list (e.g. the gateway ``/compress`` handler
               strips it before calling compress()).
       
      -        Examples:
      +        The ``protect_first_n`` portion DECAYS after the first compression
      +        (see _effective_protect_first_n) so early user turns don't fossilize
      +        across repeated compactions (#11996).
      +
      +        Examples (first compaction):
                 protect_first_n=0 → system prompt only (or nothing if no system msg)
                 protect_first_n=3 → system + first 3 non-system messages
      +        After the first compaction: system prompt only.
               """
               head = 0
               if messages and messages[0].get("role") == "system":
                   head = 1
      -        return head + self.protect_first_n
      +        return head + self._effective_protect_first_n()
       
           def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:
               """Pull a compress-end boundary backward to avoid splitting a
    • tests/agent/test_context_compressor.py
      @@ -225,6 +225,39 @@ def test_protects_first_and_last(self, compressor):
               # original content is present in either case.
               assert msgs[-2]["content"] in result[-2]["content"]
       
      +    def test_protect_first_n_decays_after_first_compression(self):
      +        """Regression for #11996: protect_first_n must protect early turns on
      +        the FIRST compaction but DECAY afterwards, so the same early user
      +        messages don't get re-copied verbatim into every child session and
      +        fossilize (grow immortal) across a long, repeatedly-compressed
      +        session. The system prompt is always protected separately."""
      +        with patch("agent.context_compressor.get_model_context_length", return_value=100000):
      +            c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3)
      +
      +        msgs = [{"role": "system", "content": "sys"}] + [
      +            {"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
      +            for i in range(10)
      +        ]
      +
      +        # First compaction: protect system + first 3 non-system.
      +        assert c.compression_count == 0
      +        assert c._effective_protect_first_n() == 3
      +        assert c._protect_head_size(msgs) == 1 + 3
      +
      +        # Simulate having compressed once — early turns now live in the summary.
      +        c.compression_count = 1
      +        assert c._effective_protect_first_n() == 0
      +        assert c._protect_head_size(msgs) == 1  # system prompt only
      +
      +    def test_protect_first_n_decays_when_previous_summary_exists(self):
      +        """Even if compression_count was reset, an existing handoff summary
      +        means the early turns are already captured — decay still applies."""
      +        with patch("agent.context_compressor.get_model_context_length", return_value=100000):
      +            c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3)
      +        c.compression_count = 0
      +        c._previous_summary = "[CONTEXT SUMMARY]: earlier work"
      +        assert c._effective_protect_first_n() == 0
      +
       
       class TestGenerateSummaryNoneContent:
           """Regression: content=None (from tool-call-only assistant messages) must not crash."""

1 PR closed without merging

From the close comments: merged via another change 1

  • #12092 fix(compressor): decay head protection across compression cycles · merged via another change → #50001
Closed issues · 1
  • Closed#11996[Bug]: protect_first_n causes head message fossilization across compressions — old user messages become immortal
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.00 means 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 Lab

At a glance

Subsystem
agent
Merged PRs
#50001
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.