diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 74e9feda2e38..7dc0ce2f73c2 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -270,12 +270,15 @@ def check_compression_model_feasibility(agent: Any) -> None: # above guarantees aux_context >= MINIMUM_CONTEXT_LENGTH, # so the new threshold is always >= 64K. # - # The compression summariser sends a single user-role - # prompt (no system prompt, no tools) to the aux model, so - # new_threshold == aux_context is safe: the request is - # the raw messages plus a small summarisation instruction. + # Apply an 80 % safety margin on the aux model's context + # window: the summarisation prompt template, system + # instructions, and tool schemas consume part of the + # context, so reserving 20 % headroom avoids passing a + # request that exceeds the aux model's actual capacity + # (see issue #53008 — a too-high threshold combined with + # no headroom can produce an infinite compression loop). old_threshold = threshold - new_threshold = aux_context + new_threshold = max(int(aux_context * 0.8), MINIMUM_CONTEXT_LENGTH) agent.context_compressor.threshold_tokens = new_threshold # Keep threshold_percent in sync so future main-model # context_length changes (update_model) re-derive from a @@ -285,7 +288,7 @@ def check_compression_model_feasibility(agent: Any) -> None: agent.context_compressor.threshold_percent = ( new_threshold / main_ctx ) - safe_pct = int((aux_context / main_ctx) * 100) if main_ctx else 50 + safe_pct = int((new_threshold / main_ctx) * 100) if main_ctx else 50 # Build human-readable "model (provider)" labels for both # the main model and the compression model so users can # tell at a glance which provider each side is actually diff --git a/agent/turn_context.py b/agent/turn_context.py index 88980b4ad276..0b7e6194a500 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -397,6 +397,7 @@ def build_turn_context( f">= {_compressor.threshold_tokens:,} threshold. " "This may take a moment." ) + _preflight_original_tokens = _preflight_tokens for _pass in range(3): _orig_len = len(messages) _orig_tokens = _preflight_tokens @@ -428,7 +429,28 @@ def build_turn_context( if not _compressor.should_compress(_preflight_tokens): break - # Plugin hook: pre_llm_call (context injected into user message, not system prompt). + # Guard against an infinite compression loop when the aux + # model is too small to compress the current session + # effectively (issue #53008). Token reduction below 10 % + # means every turn will re-trigger compression without + # making progress — raise the threshold to break the cycle. + _reduction = 1.0 - (_preflight_tokens / _preflight_original_tokens) + if _reduction < 0.10 and _compressor.should_compress(_preflight_tokens): + _old_threshold = _compressor.threshold_tokens + _compressor.threshold_tokens = max( + _preflight_tokens + 1, + int(_old_threshold * 1.5), + ) + logger.warning( + "Preflight compression ineffective " + "(%.1f%% token reduction — aux model may be too " + "small for current session). " + "Raising threshold %s → %s to prevent " + "compression loop.", + _reduction * 100, + f"{_old_threshold:,}", + f"{_compressor.threshold_tokens:,}", + ) plugin_user_context = "" try: from hermes_cli.plugins import invoke_hook as _invoke_hook