From 032a424fa4e2e18e8a7899b98183f1912a3aa872 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sat, 18 Jul 2026 15:20:03 +0700 Subject: [PATCH] fix(error_classifier): stop empty-response advisories from triggering compression Provider empty-reply text mentions "very low max_tokens", which used to match the bare overflow pattern and thrash compress until "Cannot compress further". --- agent/error_classifier.py | 56 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 396d922fb44c..9d830cdc996f 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -269,6 +269,11 @@ _CONTEXT_OVERFLOW_PATTERNS = [ "context window", "prompt is too long", "prompt exceeds max length", + # NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys + # off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT + # remove it. Provider empty-response advisories also contain "very low + # max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS + # BEFORE this list is consulted, so they never mis-route into compression. "max_tokens", "maximum number of tokens", # vLLM / local inference server patterns @@ -426,6 +431,19 @@ _THINKING_SIG_PATTERNS = [ # the exception type is generic (e.g. RuntimeError from a local shim that # wraps a subprocess timeout). Checked before the type-based transport # heuristics so custom-provider "timed out" errors don't fall through to +# Provider empty-response advisories (OpenRouter / nano-gpt / similar). +# Checked before context-overflow matching because the advisory text often +# mentions "max_tokens" as a possible cause, which historically sat in +# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression +# death spiral ending in "Cannot compress further". +_EMPTY_PROVIDER_RESPONSE_PATTERNS = [ + "returned an empty response", + "empty response despite retries", + "provider returned an empty response", + "model returning empty responses", + "empty response stream", +] + # the unknown bucket and get misreported as empty responses. _TIMEOUT_MESSAGE_PATTERNS = [ "timed out", @@ -1077,6 +1095,14 @@ def _classify_by_status( # remaining explicit context-overflow signal routes into the # compression-and-retry path (mirroring _classify_400) instead of # blind server_error retries that exhaust and drop the turn. + # Empty-response advisories that mention "max_tokens" must not enter + # that compression path. + if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS): + return result_fn( + FailoverReason.server_error, + retryable=True, + should_compress=False, + ) if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn( FailoverReason.context_overflow, @@ -1090,6 +1116,12 @@ def _classify_by_status( # Cloudflare/Tailscale hop relabeling the status). Route explicit # overflow bodies into compression; otherwise treat as transient # overload and retry. + if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS): + return result_fn( + FailoverReason.server_error, + retryable=True, + should_compress=False, + ) if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn( FailoverReason.context_overflow, @@ -1215,8 +1247,8 @@ def _classify_400( # returns: # "Unsupported parameter: 'max_tokens' is not supported with this model. # Use 'max_completion_tokens' instead." - # That string contains the literal substring "max_tokens", which is one of - # the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is + # That string contains the literal substring "max_tokens", which historically + # sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is # misclassified as context_overflow, routed into the compression loop, # re-sent with the same bad parameter, and ends in "Cannot compress # further". These errors are deterministic (every retry gets the identical @@ -1238,6 +1270,17 @@ def _classify_400( should_fallback=True, ) + # Empty-provider-response advisories must not enter compression. They + # often mention "max_tokens" as a possible cause and used to match the + # bare overflow pattern, then thrash compress until "Cannot compress + # further" on an otherwise healthy session (custom endpoints / nano-gpt). + if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS): + return result_fn( + FailoverReason.server_error, + retryable=True, + should_compress=False, + ) + # Context overflow from 400 if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn( @@ -1441,6 +1484,15 @@ def _classify_by_message( should_fallback=True, ) + # Empty-provider-response advisories (often mention "max_tokens") must + # retry without compression — see the matching 400-path guard above. + if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS): + return result_fn( + FailoverReason.server_error, + retryable=True, + should_compress=False, + ) + # Context overflow patterns if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn(