fix(agent): make the compression retry cap config-driven (compression.max_attempts)

The conversation loop hardcodes max_compression_attempts = 3. Sessions
that legitimately need more rounds are stranded: on a restart history
reload, incompressible tool schemas can keep the per-request estimate
above the compressor threshold even though the message floor compresses
correctly, so three rounds cannot clear it and the turn dies with
"Context length exceeded: max compression attempts (3) reached" — the
same failure class as #62605, where the rough estimate similarly leaves
3 retries short.

Make the cap a config key, compression.max_attempts:

- default 3 = identical to today, so an unset key is behavior-neutral;
- parsed and validated in agent_init alongside the other compression.*
  keys (>= 1, hard-capped at 10, non-integer values fall back to 3),
  attached as agent.max_compression_attempts;
- the loop reads it via getattr(agent, "max_compression_attempts", 3),
  so objects without the attribute keep the prior behavior;
- documented in the DEFAULT_CONFIG compression block.

Tests pin the parse/validate/attach seam: default preserved, custom
value honored, floor and ceiling enforced, garbage tolerated, and the
loop-side getattr degradation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kennedy Umege 2026-07-13 23:07:45 +01:00 committed by Teknium
parent 28b6bd6570
commit 644b397fb2
5 changed files with 128 additions and 1 deletions

View file

@ -1781,6 +1781,21 @@ 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))
# 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
# history reload whose incompressible tool schemas keep the request
# estimate above the threshold even though the messages compress fine
# (the #62605 failure class). Default 3 preserves current behavior, so
# an unset key is behavior-neutral; validated >= 1, hard-capped at 10,
# and a non-integer value falls back to 3.
try:
compression_max_attempts = int(_compression_cfg.get("max_attempts", 3))
except (TypeError, ValueError):
compression_max_attempts = 3
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
# protect_first_n is the number of non-system messages to protect at
# the head, in addition to the system prompt (which is always
# implicitly protected by the compressor). Floor at 0 — a value of
@ -2224,6 +2239,7 @@ def init_agent(
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).

View file

@ -1252,7 +1252,9 @@ def run_conversation(
retry_count = 0
max_retries = agent._api_max_retries
_retry = TurnRetryState()
max_compression_attempts = 3
# Config-driven via compression.max_attempts (parsed + validated in
# agent_init). Default 3 preserves the prior hardcoded behavior.
max_compression_attempts = getattr(agent, "max_compression_attempts", 3)
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail

View file

@ -429,6 +429,12 @@ compression:
# compression of older turns.
protect_last_n: 20
# 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
# the request estimate under the threshold. Validated >= 1, hard cap 10.
max_attempts: 3
# Codex app-server (codex CLI runtime) thread-compaction mode. The codex
# agent owns the real thread context on this runtime, so Hermes' summarizer
# cannot shrink it — compaction goes through the app server instead.

View file

@ -1471,6 +1471,11 @@ DEFAULT_CONFIG = {
# set this above 0.75 to override the floor.
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
"protect_last_n": 20, # minimum recent messages to keep uncompressed
"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
# rounds cannot clear the request estimate.
# Validated >= 1, hard-capped at 10.
"hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count
"protect_first_n": 3, # non-system head messages always preserved
# verbatim, in ADDITION to the system prompt

View file

@ -0,0 +1,98 @@
"""compression.max_attempts — config-driven compression retry cap.
The conversation loop's compression retry cap was hardcoded to 3, stranding
sessions that legitimately need more rounds e.g. a restart history reload
whose incompressible tool schemas keep the request estimate above the
threshold while the messages themselves compress fine (the #62605 failure
class). The cap is now parsed from ``compression.max_attempts`` in
``agent_init`` and read by the loop via
``getattr(agent, "max_compression_attempts", 3)``.
These tests pin the parse/validate/attach seam: default preserved, custom
value honored, floor and ceiling enforced, garbage tolerated.
"""
from __future__ import annotations
import contextlib
import io
from pathlib import Path
from hermes_state import SessionDB
from run_agent import AIAgent
def _config(max_attempts=None) -> dict:
compression = {
"enabled": True,
"threshold": 0.50,
"target_ratio": 0.20,
"protect_first_n": 3,
"protect_last_n": 20,
}
if max_attempts is not None:
compression["max_attempts"] = max_attempts
return {
"compression": compression,
"prompt_caching": {"cache_ttl": "5m"},
"sessions": {},
"bedrock": {},
}
def _make_agent(monkeypatch, tmp_path: Path, *, max_attempts=None):
from hermes_cli import config as config_mod
monkeypatch.setattr(
config_mod, "load_config", lambda: _config(max_attempts=max_attempts)
)
db = SessionDB(db_path=tmp_path / "state.db")
with contextlib.redirect_stdout(io.StringIO()):
agent = AIAgent(
base_url="https://chatgpt.com/backend-api/codex",
api_key="test-key",
provider="openai-codex",
model="gpt-5.5",
enabled_toolsets=[],
disabled_toolsets=[],
quiet_mode=True,
skip_memory=True,
session_db=db,
session_id="max-attempts-test",
)
return agent
class TestCompressionMaxAttemptsConfig:
def test_default_is_three_when_unset(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path)
assert agent.max_compression_attempts == 3
def test_custom_value_is_honored(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=6)
assert agent.max_compression_attempts == 6
def test_hard_capped_at_ten(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=25)
assert agent.max_compression_attempts == 10
def test_zero_and_negative_fall_back_to_default(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts=0)
assert agent.max_compression_attempts == 3
agent = _make_agent(monkeypatch, tmp_path, max_attempts=-2)
assert agent.max_compression_attempts == 3
def test_non_integer_falls_back_to_default(self, monkeypatch, tmp_path):
agent = _make_agent(monkeypatch, tmp_path, max_attempts="lots")
assert agent.max_compression_attempts == 3
def test_loop_pickup_degrades_to_default_when_attribute_missing(
self, monkeypatch, tmp_path
):
# The loop reads getattr(agent, "max_compression_attempts", 3): a
# configured agent exposes its value, and an object without the
# attribute (older pickle / minimal stub) degrades to the prior
# hardcoded behavior.
agent = _make_agent(monkeypatch, tmp_path, max_attempts=7)
assert getattr(agent, "max_compression_attempts", 3) == 7
assert getattr(object(), "max_compression_attempts", 3) == 3