fix(compress): gate N-user tail guarantee to actionable turns, behavior-preserving default

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.
This commit is contained in:
Teknium 2026-07-23 11:55:20 -07:00
parent a9c868225e
commit d43cc2ca80
8 changed files with 282 additions and 15 deletions

View file

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

View file

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

View file

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

2
cli.py
View file

@ -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)

View file

@ -0,0 +1 @@
zhangyang-crazy-one

View file

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

View file

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

View file

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