From 644b397fb29768bdfc8c3ada79a0d8fcf5349ff9 Mon Sep 17 00:00:00 2001 From: Kennedy Umege Date: Mon, 13 Jul 2026 23:07:45 +0100 Subject: [PATCH] fix(agent): make the compression retry cap config-driven (compression.max_attempts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agent/agent_init.py | 16 +++ agent/conversation_loop.py | 4 +- cli-config.yaml.example | 6 ++ hermes_cli/config.py | 5 + .../test_compression_max_attempts_config.py | 98 +++++++++++++++++++ 5 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_compression_max_attempts_config.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 599927ba0c78..722088106b0f 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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). diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 517c9d86ed7e..a27c084ee77d 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 diff --git a/cli-config.yaml.example b/cli-config.yaml.example index d12d4ff63052..d04cde3ec528 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7a527b75f53c..8290887230d7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/tests/agent/test_compression_max_attempts_config.py b/tests/agent/test_compression_max_attempts_config.py new file mode 100644 index 000000000000..cd9429cae471 --- /dev/null +++ b/tests/agent/test_compression_max_attempts_config.py @@ -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