From d43cc2ca80c9f6e332eccd67dc8724224f64ee3d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:55:20 -0700 Subject: [PATCH] fix(compress): gate N-user tail guarantee to actionable turns, behavior-preserving default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up fixes on top of the salvaged #22566 mechanism: - N-collector now counts only REAL actionable user turns via _is_actionable_user_turn + _is_synthetic_compression_user_turn — the same filter pair _find_last_user_message_idx uses post-#69291. The contributor's bare role=='user' + _is_context_summary_content check let blank platform echoes and continuation/todo rows consume N slots, silently degrading the guarantee. - Default flipped 3 -> 1 (behavior-preserving): a default of 3 was measured to change the tail cut on transcripts whose budget covers only the last turn. min_tail_user_messages=1 delegates to the existing single-user anchor; N>1 is opt-in, and the call site is gated so the default path is byte-identical to main. - Hardened config parse in agent_init (bool rejected, fractional floats rejected, floor 1) matching the max_attempts parser shape. - Wired the recurring external-PR config gaps: hermes_cli/config.py DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py). - Regression tests: blank echoes / synthetic rows don't count toward N; tool-call/result pairs never split by the N-boundary (no-orphan both directions); N-guarantee wins over tail_token_budget and the _MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default parity pin; DEFAULT_CONFIG pin. --- agent/agent_init.py | 23 +- agent/context_compressor.py | 40 ++-- cli-config.yaml.example | 9 + cli.py | 2 +- contributors/emails/jerry@hermes.local | 1 + hermes_cli/config.py | 6 + tests/agent/test_context_compressor.py | 214 ++++++++++++++++++ .../context-compression-and-caching.md | 2 + 8 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 contributors/emails/jerry@hermes.local diff --git a/agent/agent_init.py b/agent/agent_init.py index 6271453cd1e0..26a4c7876174 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1810,7 +1810,28 @@ def init_agent( compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) - compression_min_tail_users = int(_compression_cfg.get("min_tail_user_messages", 3)) + # Minimum REAL (actionable) user messages guaranteed to survive in the + # uncompressed tail (compression.min_tail_user_messages). Default 1 + # preserves current behavior exactly — the existing single-user tail + # anchor. Values > 1 extend the guarantee to the last N actionable + # user turns. Booleans rejected (bool subclasses int), non-int-like + # values fall back to 1, floor at 1. + _raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1) + if isinstance(_raw_min_tail_users, bool): + compression_min_tail_users = 1 + elif isinstance(_raw_min_tail_users, int): + compression_min_tail_users = _raw_min_tail_users + elif isinstance(_raw_min_tail_users, float): + compression_min_tail_users = ( + int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1 + ) + else: + try: + compression_min_tail_users = int(str(_raw_min_tail_users).strip()) + except (TypeError, ValueError): + compression_min_tail_users = 1 + if compression_min_tail_users < 1: + compression_min_tail_users = 1 # Cap on compression retry rounds before a turn gives up with "max # compression attempts reached" (compression.max_attempts). Hardcoding 3 # strands sessions that legitimately need more rounds — e.g. a restart diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8166398175e8..249678f57454 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4555,18 +4555,25 @@ This compaction should PRIORITISE preserving all information related to the focu head_end: int, n: int, ) -> int: - """Guarantee the last N user messages are in the protected tail. + """Guarantee the last N actionable user messages are in the protected tail. Generalizes ``_ensure_last_user_message_in_tail`` to preserve an arbitrary number of recent user messages. This prevents the token- budget-based tail cut from consuming recent conversation turns - when large tool outputs fill the budget (COMPRESS-01). + when large tool outputs fill the budget. When *n* <= 1, delegates directly to the existing single-message - method for byte-identical regression safety (COMPRESS-08). + method for byte-identical regression safety. If the conversation has fewer than *n* user messages, the earliest - available user message is used without error (COMPRESS-07). + available user message is used without error. + + Only REAL actionable user turns count toward N — the collector uses + the same ``_is_actionable_user_turn`` / + ``_is_synthetic_compression_user_turn`` pair as + ``_find_last_user_message_idx``, so blank platform echoes, compaction + handoffs, continuation markers, and todo-snapshot rows never consume + a slot (#69291 bug class). A user message is already a clean boundary — there is no tool_call/result group that spans across it, so @@ -4578,13 +4585,15 @@ This compaction should PRIORITISE preserving all information related to the focu return self._ensure_last_user_message_in_tail(messages, cut_idx, head_end) # Collect real user message indices walking backward from end. - # Skip context-summary handoff banners — they are internal - # continuity state, not real user turns. + # Mirror _find_last_user_message_idx's filters: compaction handoffs, + # blank platform echoes, and synthetic continuation/todo rows are + # continuity artifacts, not real user turns. user_indices = [] for i in range(len(messages) - 1, head_end - 1, -1): msg = messages[i] - if msg.get("role") == "user" and not self._is_context_summary_content( - msg.get("content") + if ( + self._is_actionable_user_turn(msg) + and not self._is_synthetic_compression_user_turn(msg) ): user_indices.append(i) @@ -4732,15 +4741,20 @@ This compaction should PRIORITISE preserving all information related to the focu cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end) # Extend to the last N actionable user messages when configured - # (compression.min_tail_user_messages). This prevents the + # (compression.min_tail_user_messages > 1). This prevents the # token-budget tail from consuming recent turns when large tool # outputs fill the budget. The anchor only walks ``cut_idx`` # backward (monotonic — the tail can only grow, never shrink), and # a user message is a clean boundary, so the forward re-alignment - # below remains a no-op for the anchored index. - cut_idx = self._ensure_last_n_user_messages_in_tail( - messages, cut_idx, head_end, self.min_tail_user_messages, - ) + # below remains a no-op for the anchored index. Gated at the call + # site so the default (1) path is byte-identical to the historical + # single-anchor pipeline — the single-user anchor already ran above, + # and re-invoking it here could re-trigger the causal-coupling + # forward push (#22523) after the assistant anchor adjusted the cut. + if self.min_tail_user_messages > 1: + cut_idx = self._ensure_last_n_user_messages_in_tail( + messages, cut_idx, head_end, self.min_tail_user_messages, + ) # The floor guarantees forward progress — compression must always claim # at least one message or the caller's compress_start >= compress_end diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 847bc98693fd..4374d7885226 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -448,6 +448,15 @@ compression: # compression of older turns. protect_last_n: 20 + # Minimum number of REAL (actionable) user messages guaranteed to survive in + # the uncompressed tail (default: 1 = the existing single last-user anchor, + # behavior-preserving). Raise to e.g. 3 to keep the last 3 real user turns + # verbatim even when bulky tool outputs fill the tail token budget — blank + # platform echoes, compaction handoffs, and synthetic continuation rows never + # count toward N. The tail can exceed the token budget when this pulls the + # cut back; the guarantee wins over the budget by design. + min_tail_user_messages: 1 + # Compression retry rounds before a turn gives up with "max compression # attempts reached" (default: 3, same as the previous hardcoded value). # Raise (e.g. 6) for tool-schema-heavy sessions where 3 rounds cannot bring diff --git a/cli.py b/cli.py index d0515cb10720..9d3b6fc4cf5b 100644 --- a/cli.py +++ b/cli.py @@ -468,7 +468,7 @@ def load_cli_config() -> Dict[str, Any]: "compression": { "enabled": True, # Auto-compress when approaching context limit "threshold": 0.50, # Compress at 50% of model's context limit - "min_tail_user_messages": 3, # Min recent user messages to preserve in tail after compression + "min_tail_user_messages": 1, # Real user messages guaranteed in the tail (1 = existing single anchor) }, "agent": { "max_turns": 90, # Default max tool-calling iterations (shared with subagents) diff --git a/contributors/emails/jerry@hermes.local b/contributors/emails/jerry@hermes.local new file mode 100644 index 000000000000..fdb939b79e84 --- /dev/null +++ b/contributors/emails/jerry@hermes.local @@ -0,0 +1 @@ +zhangyang-crazy-one diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 640c184f0cc0..765891e2db38 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1409,6 +1409,12 @@ DEFAULT_CONFIG = { # the model's context length at apply-time. "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed + "min_tail_user_messages": 1, # REAL (actionable) user messages guaranteed to + # survive in the uncompressed tail. 1 = existing + # single last-user anchor (default, behavior- + # preserving); raise to e.g. 3 to keep the last + # 3 real user turns verbatim when bulky tool + # outputs fill the tail token budget. "max_attempts": 3, # compression retry rounds before a turn gives up # with "max compression attempts reached". Raise # (e.g. 6) for tool-schema-heavy sessions where 3 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 26446e266ab9..6364441f2a24 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -4094,6 +4094,7 @@ class TestMinTailUserMessages: threshold_percent=0.50, protect_first_n=2, quiet_mode=True, + min_tail_user_messages=3, ) c.tail_token_budget = 200 messages = [ @@ -4134,6 +4135,7 @@ class TestMinTailUserMessages: threshold_percent=0.50, protect_first_n=1, quiet_mode=True, + min_tail_user_messages=3, ) c.tail_token_budget = 200 messages = [ @@ -4299,3 +4301,215 @@ class TestMinTailUserMessages: messages, cut_idx=2, head_end=head_end, n=3 ) assert result == 2 # unchanged + + def test_default_is_behavior_preserving(self): + """Default min_tail_user_messages=1 leaves the tail cut byte-identical + to the historical single-anchor pipeline. + + A default of 3 was measured to CHANGE the cut on transcripts whose + tail budget covers only the last turn, so the default is gated to 1 + (= the existing single last-user anchor) and N>1 is opt-in. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c_default = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + ) + c_explicit = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=2, + quiet_mode=True, + min_tail_user_messages=1, + ) + assert c_default.min_tail_user_messages == 1 + c_default.tail_token_budget = 200 + c_explicit.tail_token_budget = 200 + messages = [ + {"role": "user", "content": "head msg"}, + {"role": "assistant", "content": "head reply"}, + ] + for i in range(3): + messages.append({"role": "user", "content": f"user {i}"}) + messages.append({"role": "assistant", "content": "X" * 4000}) + messages.append({"role": "user", "content": "final user"}) + messages.append({"role": "assistant", "content": "final reply"}) + head_end = c_default.protect_first_n + assert ( + c_default._find_tail_cut_by_tokens(messages, head_end) + == c_explicit._find_tail_cut_by_tokens(messages, head_end) + ) + + def test_blank_echo_does_not_count_toward_n(self): + """A blank platform echo (empty user row) must not consume one of the + N slots — otherwise the guarantee silently degrades to N-1 real turns + (the #69291 bug class the single anchor already fixed).""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + messages = [ + {"role": "user", "content": "head"}, # 0 (head) + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real oldest"}, # 2 <- 3rd real user + {"role": "assistant", "content": "reply oldest"}, # 3 + {"role": "user", "content": "real middle"}, # 4 + {"role": "assistant", "content": "reply middle"}, # 5 + {"role": "user", "content": ""}, # 6 blank echo + {"role": "assistant", "content": "reply to echo"}, # 7 + {"role": "user", "content": " "}, # 8 whitespace echo + {"role": "assistant", "content": "another reply"}, # 9 + {"role": "user", "content": "real latest"}, # 10 + {"role": "assistant", "content": "final reply"}, # 11 + ] + head_end = c.protect_first_n # = 1 + # cut_idx=10 → only "real latest" in tail; N=3 must walk back to + # index 2 ("real oldest"), NOT stop at a blank echo (6/8). + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=10, head_end=head_end, n=3 + ) + assert result == 2, ( + f"3rd real user is at index 2, got cut {result} — blank echoes " + "must not count toward N" + ) + tail_users = [ + m["content"] for m in messages[result:] + if m["role"] == "user" and m["content"].strip() + ] + assert {"real oldest", "real middle", "real latest"} <= set(tail_users) + + def test_synthetic_compression_rows_do_not_count_toward_n(self): + """Compaction handoff banners and continuation markers carry + role="user" after SessionDB projection but are continuity artifacts — + they must not consume N slots.""" + from agent.context_compressor import ( + COMPRESSION_CONTINUATION_USER_CONTENT, + ) + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=2, + ) + messages = [ + {"role": "user", "content": "head"}, # 0 (head) + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real second"}, # 2 + {"role": "assistant", "content": "reply second"}, # 3 + {"role": "user", "content": SUMMARY_PREFIX + " old summary"}, # 4 handoff + {"role": "assistant", "content": "ack"}, # 5 + {"role": "user", "content": COMPRESSION_CONTINUATION_USER_CONTENT}, # 6 marker + {"role": "assistant", "content": "ack 2"}, # 7 + {"role": "user", "content": "real latest"}, # 8 + {"role": "assistant", "content": "final reply"}, # 9 + ] + head_end = c.protect_first_n + result = c._ensure_last_n_user_messages_in_tail( + messages, cut_idx=8, head_end=head_end, n=2 + ) + assert result == 2, ( + f"2nd real user is at index 2, got cut {result} — synthetic " + "compression rows must not count toward N" + ) + + def test_n_boundary_never_orphans_tool_results(self): + """Integration: with N=3 the full tail-cut pipeline must never place + a tool result in the tail whose parent assistant(tool_calls) was + summarized away, or vice versa (no-orphan in BOTH directions).""" + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + c.tail_token_budget = 100 + messages = [ + {"role": "user", "content": "head"}, # 0 + {"role": "assistant", "content": "head reply"}, # 1 + {"role": "user", "content": "real 3"}, # 2 + {"role": "assistant", "content": None, + "tool_calls": [{"id": "call_a", "function": {"name": "t", "arguments": "{}"}}]}, # 3 + {"role": "tool", "content": "R" * 2000, "tool_call_id": "call_a"}, # 4 + {"role": "assistant", "content": "reply 3"}, # 5 + {"role": "user", "content": "real 2"}, # 6 + {"role": "assistant", "content": None, + "tool_calls": [{"id": "call_b", "function": {"name": "t", "arguments": "{}"}}]}, # 7 + {"role": "tool", "content": "S" * 2000, "tool_call_id": "call_b"}, # 8 + {"role": "assistant", "content": "reply 2"}, # 9 + {"role": "user", "content": "real 1"}, # 10 + {"role": "assistant", "content": "reply 1"}, # 11 + ] + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail = messages[cut:] + tail_call_ids = { + tc.get("id") + for m in tail if m.get("role") == "assistant" + for tc in (m.get("tool_calls") or []) + } + tail_result_ids = { + m.get("tool_call_id") for m in tail if m.get("role") == "tool" + } + assert tail_call_ids == tail_result_ids, ( + f"tool pair split across N-boundary: calls={tail_call_ids} " + f"results={tail_result_ids}" + ) + tail_users = [m["content"] for m in tail if m["role"] == "user"] + assert {"real 1", "real 2", "real 3"} <= set(tail_users) + + def test_n_guarantee_wins_over_tail_token_budget_and_floor(self): + """Interaction contract: the N-user guarantee WINS over both + tail_token_budget and _MAX_TAIL_MESSAGE_FLOOR. + + The budget walk (and its bounded message floor) computes the initial + cut; the N-anchor then only ever pulls the cut BACKWARD (tail can + grow, never shrink), exactly like the existing single-user and + assistant anchors. So a tiny budget cannot roll real users 2..N into + the summary, and the floor remains a lower bound, not a cap, on the + anchored tail. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + quiet_mode=True, + min_tail_user_messages=3, + ) + # Budget covers roughly one bulky turn — without the N-anchor the + # cut lands after users 2..3. + c.tail_token_budget = 150 + messages = [{"role": "user", "content": "head"}, + {"role": "assistant", "content": "head reply"}] + for i in (3, 2, 1): + messages.append({"role": "user", "content": f"real {i}"}) + messages.append({"role": "assistant", "content": "B" * 6000}) + head_end = c.protect_first_n + cut = c._find_tail_cut_by_tokens(messages, head_end) + tail = messages[cut:] + tail_users = [m["content"] for m in tail if m["role"] == "user"] + assert {"real 1", "real 2", "real 3"} <= set(tail_users), ( + f"N-guarantee must win over the token budget; tail users: {tail_users}" + ) + # The anchored tail legitimately exceeds the budget (and the 8-message + # floor is a minimum, not a cap): the guarantee is the stronger + # invariant by design. + from agent.context_compressor import _estimate_msg_budget_tokens + accumulated = sum(_estimate_msg_budget_tokens(m) for m in tail) + assert accumulated > c.tail_token_budget + + def test_default_config_ships_behavior_preserving_value(self): + """DEFAULT_CONFIG ships min_tail_user_messages=1 so an unset key is + exactly the pre-feature single-anchor behavior.""" + from hermes_cli.config import DEFAULT_CONFIG + assert DEFAULT_CONFIG["compression"]["min_tail_user_messages"] == 1 diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index e3eb2c4d8f2d..c27f371c3965 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -87,6 +87,7 @@ compression: # "claude-sonnet": 0.35 # overrides" below. target_ratio: 0.20 # How much of threshold to keep as tail (default: 0.20) protect_last_n: 20 # Minimum protected tail messages (default: 20) + min_tail_user_messages: 1 # Real user messages guaranteed in the tail (default: 1) codex_gpt55_autoraise: true # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true) codex_gpt55_autoraise_notice: true # Show the one-time autoraise notice (default: true) codex_app_server_auto: native # native|hermes|off for Codex app-server thread compaction @@ -107,6 +108,7 @@ auxiliary: | `model_thresholds` | `{}` | map | Per-model overrides of `threshold`. Keys are substring-matched against the model name (longest match wins). The small-context floor still applies on top (see below) | | `target_ratio` | `0.20` | 0.10-0.80 | Controls tail protection token budget: `threshold_tokens × target_ratio` | | `protect_last_n` | `20` | ≥1 | Minimum number of recent messages always preserved | +| `min_tail_user_messages` | `1` | ≥1 | Minimum number of REAL (actionable) user messages guaranteed to survive in the uncompressed tail. `1` = the existing single last-user anchor (behavior-preserving default). Raise to e.g. `3` to keep the last 3 real user turns verbatim even when bulky tool outputs fill the tail token budget. Blank platform echoes, compaction handoffs, and synthetic continuation rows never count toward N. The guarantee wins over the tail token budget — the tail may exceed the budget when the anchor pulls the cut back | | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | | `idle_compact_after_seconds` | `0` | ≥0 seconds | Opt-in: compact up front when a session resumes after this many seconds idle (0 = disabled). Skips when context ≤ threshold × target_ratio; honors cooldown/anti-thrash/lock guards | | `codex_gpt55_autoraise` | `true` | bool | Raise the trigger to 85% for gpt-5.5 on the ChatGPT Codex OAuth route (see below). Set `false` to keep the global `threshold` |