Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ def is_auth(self) -> bool:
"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
Expand Down Expand Up @@ -426,6 +431,19 @@ def is_auth(self) -> bool:
# 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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions tests/agent/test_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,38 @@ def test_400_unsupported_max_tokens_param_not_context_overflow(self):
assert result.retryable is False
assert result.should_compress is False

def test_empty_provider_response_advisory_not_context_overflow(self):
"""nano-gpt / OpenRouter empty-response advisories mention
'very low max_tokens' as a possible cause. That used to match the
bare 'max_tokens' overflow pattern and thrash compression until
'Cannot compress further' on a healthy session."""
msg = (
"The model returned an empty response despite retries across "
"available sources. This is usually a temporary upstream issue "
"and retrying the request often succeeds. Less commonly it can "
"be caused by stop sequences matching the output, a very low "
"max_tokens, or content filtering. No charge was applied."
)
result = classify_api_error(
Exception(msg),
approx_tokens=143000,
context_length=1_048_576,
num_messages=300,
)
assert result.reason == FailoverReason.server_error
assert result.retryable is True
assert result.should_compress is False

def test_max_tokens_exceeded_still_context_overflow(self):
"""Specific max_tokens-exceeded phrasing must keep compressing."""
result = classify_api_error(
Exception("Request failed: max_tokens exceeded for this model"),
approx_tokens=200000,
context_length=128000,
)
assert result.reason == FailoverReason.context_overflow
assert result.should_compress is True

def test_400_unknown_parameter_not_context_overflow(self):
"""'Unknown parameter' 400s are deterministic request-validation
failures, not overflows."""
Expand Down
Loading