mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-07 13:02:07 +00:00
fix(agent): stop over-cap max_tokens 400s from death-looping into compression (#55570)
An over-cap model.max_tokens produces a provider 400 that mentions max_tokens, which trips _CONTEXT_OVERFLOW_PATTERNS and is classified as context_overflow. On providers whose wording isn't recognized by parse_available_output_tokens_from_error() (e.g. DashScope/Qwen: "Range of max_tokens should be [1, 65536]") the smart-retry is skipped and the error falls into the compression fallback, which re-sends the same oversized max_tokens, fails identically, and loops until "cannot compress further" on a tiny conversation (#55546). Root-cause fix for the whole class, not just DashScope: - parse_available_output_tokens_from_error(): recognize the DashScope "Range of max_tokens should be [1, N]" form and return N (smart-retry then caps output and retries WITHOUT compressing). - new is_output_cap_error(): broader yes/no gate for output-cap 400s. In the loop, when the error is output-cap-shaped but unparseable, fail fast with an actionable message (lower model.max_tokens) instead of routing into compression. Mirrors the existing GPT-5 max_tokens guard. Real input overflows and GPT-5 unsupported-param 400s are unchanged.
This commit is contained in:
parent
62b9fb6623
commit
c7e0bdef9a
3 changed files with 182 additions and 1 deletions
|
|
@ -52,6 +52,7 @@ from agent.model_metadata import (
|
|||
estimate_messages_tokens_rough,
|
||||
estimate_request_tokens_rough,
|
||||
get_context_length_from_provider_error,
|
||||
is_output_cap_error,
|
||||
parse_available_output_tokens_from_error,
|
||||
save_context_length,
|
||||
)
|
||||
|
|
@ -3213,6 +3214,45 @@ def run_conversation(
|
|||
_retry.restart_with_compressed_messages = True
|
||||
break
|
||||
|
||||
# The error is output-cap-shaped (about max_tokens being
|
||||
# too large) but the provider's wording didn't let us parse
|
||||
# the available output budget. Compression CANNOT help here
|
||||
# — the input already fits; the call fails deterministically
|
||||
# on the oversized max_tokens. Routing it into compression
|
||||
# re-sends the same max_tokens, gets the identical 400, and
|
||||
# death-loops until "cannot compress further" (#55546).
|
||||
# Fail fast with an actionable message instead of looping.
|
||||
if is_output_cap_error(error_msg):
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}❌ The provider rejected the request because "
|
||||
f"max_tokens exceeds its output cap for this model.",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 Lower model.max_tokens in your config.yaml to "
|
||||
f"at or below the model's max-output limit. "
|
||||
f"(This is an output-cap error, not a context overflow — "
|
||||
f"compression cannot fix it.)",
|
||||
force=True,
|
||||
)
|
||||
logger.error(
|
||||
f"{agent.log_prefix}Output-cap error not routed into compression "
|
||||
f"(max_tokens over provider cap): {error_msg[:200]}"
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"messages": messages,
|
||||
"completed": False,
|
||||
"api_calls": api_call_count,
|
||||
"error": (
|
||||
"max_tokens exceeds the provider's output cap for this model. "
|
||||
"Lower model.max_tokens in config.yaml."
|
||||
),
|
||||
"partial": True,
|
||||
"failed": True,
|
||||
}
|
||||
|
||||
# Error is about the INPUT being too large. Only reduce
|
||||
# context_length when the provider explicitly reports the
|
||||
# real lower limit. If the provider only says "input
|
||||
|
|
|
|||
|
|
@ -1075,10 +1075,29 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||
"maximum context length" in error_lower
|
||||
and "requested" in error_lower
|
||||
and "output tokens" in error_lower
|
||||
) or (
|
||||
# DashScope / Alibaba Cloud (Qwen) phrasing. The provider rejects an
|
||||
# over-cap output request with a bounded range whose upper bound IS the
|
||||
# real max-output cap, e.g.
|
||||
# "Range of max_tokens should be [1, 65536]"
|
||||
# The input itself fits — this is purely an output-cap error, so reduce
|
||||
# max_tokens and retry; do NOT compress.
|
||||
"range of max_tokens should be" in error_lower
|
||||
)
|
||||
if not is_output_cap_error:
|
||||
return None
|
||||
|
||||
# DashScope / Alibaba range form: "Range of max_tokens should be [1, 65536]".
|
||||
# The upper bound is the available output cap.
|
||||
_m_range = re.search(
|
||||
r'range of max_tokens should be\s*\[\s*\d+\s*,\s*(\d+)\s*\]',
|
||||
error_lower,
|
||||
)
|
||||
if _m_range:
|
||||
_cap = int(_m_range.group(1))
|
||||
if _cap >= 1:
|
||||
return _cap
|
||||
|
||||
# Extract the available_tokens figure.
|
||||
# Anthropic format: "… = available_tokens: 10000"
|
||||
patterns = [
|
||||
|
|
@ -1125,6 +1144,70 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
|
||||
def is_output_cap_error(error_msg: str) -> bool:
|
||||
"""Return True if a 400 is about the OUTPUT cap (max_tokens) being too large.
|
||||
|
||||
This is the broader sibling of :func:`parse_available_output_tokens_from_error`:
|
||||
that function only returns a number when it can extract the available output
|
||||
budget from a *known* provider phrasing. This one answers the cheaper
|
||||
yes/no question — "is this an output-cap error at all?" — across providers
|
||||
whose exact wording we may not yet parse a number from.
|
||||
|
||||
Why this matters: an output-cap 400 is deterministic (every retry with the
|
||||
same ``max_tokens`` gets the identical rejection). If such an error is
|
||||
misclassified as a context-overflow it gets routed into the compression
|
||||
loop, the compressor re-issues the call with the same oversized
|
||||
``max_tokens``, the provider rejects it identically, and the session
|
||||
death-loops until "cannot compress further" (issue #55546, DashScope/Qwen:
|
||||
"Range of max_tokens should be [1, 65536]"). Compression cannot help an
|
||||
output-cap error — the input already fits.
|
||||
|
||||
The signal: the error talks about ``max_tokens`` (or its aliases) as a
|
||||
cap/range/limit, and does NOT talk about the INPUT/prompt/context window
|
||||
being too long. When both are present we defer to the context-overflow
|
||||
path (a real input overflow can also mention max_tokens).
|
||||
"""
|
||||
error_lower = error_msg.lower()
|
||||
|
||||
mentions_output_param = (
|
||||
"max_tokens" in error_lower
|
||||
or "max_output_tokens" in error_lower
|
||||
or "max_completion_tokens" in error_lower
|
||||
)
|
||||
if not mentions_output_param:
|
||||
return False
|
||||
|
||||
# Phrasing that signals the OUTPUT cap specifically is the problem.
|
||||
output_cap_signal = (
|
||||
"range of max_tokens should be" in error_lower # DashScope / Alibaba
|
||||
or "available_tokens" in error_lower # Anthropic
|
||||
or "available tokens" in error_lower
|
||||
or ("in the output" in error_lower # OpenRouter / Nous
|
||||
and "maximum context length" in error_lower)
|
||||
or ("requested" in error_lower # LM Studio / llama.cpp
|
||||
and "output tokens" in error_lower)
|
||||
or "should be" in error_lower # generic "max_tokens should be <= N"
|
||||
or "less than or equal" in error_lower
|
||||
or "must be" in error_lower
|
||||
)
|
||||
if not output_cap_signal:
|
||||
return False
|
||||
|
||||
# If the error ALSO clearly describes an oversized INPUT, it is a genuine
|
||||
# context overflow that happens to mention max_tokens — let the
|
||||
# context-overflow path handle it (it can compress the input).
|
||||
input_overflow_signal = (
|
||||
"prompt is too long" in error_lower
|
||||
or "prompt too long" in error_lower
|
||||
or "input is too long" in error_lower
|
||||
or "input token" in error_lower
|
||||
or "prompt length" in error_lower
|
||||
or "prompt contains" in error_lower
|
||||
or "reduce the length" in error_lower
|
||||
)
|
||||
return not input_overflow_signal
|
||||
|
||||
|
||||
def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
|
||||
"""Return True if *candidate_id* (from server) matches *lookup_model* (configured).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import pytest
|
||||
from agent.model_metadata import parse_available_output_tokens_from_error
|
||||
from agent.model_metadata import (
|
||||
is_output_cap_error,
|
||||
parse_available_output_tokens_from_error,
|
||||
)
|
||||
|
||||
|
||||
class TestParseOpenRouterOutputCap:
|
||||
|
|
@ -62,3 +65,58 @@ class TestParseCharBasedOutputCap:
|
|||
msg = ("maximum context length is 1000 tokens. However, you requested "
|
||||
"1000 output tokens and your prompt contains 9000 characters.")
|
||||
assert parse_available_output_tokens_from_error(msg) is None
|
||||
|
||||
|
||||
class TestParseDashScopeOutputCap:
|
||||
"""DashScope / Alibaba Cloud (Qwen) reject an over-cap output request with
|
||||
a bounded range whose upper bound is the real max-output cap (#55546)."""
|
||||
|
||||
def test_dashscope_range_format(self):
|
||||
msg = ("HTTP 400: InternalError.Algo.InvalidParameter: "
|
||||
"Range of max_tokens should be [1, 65536]")
|
||||
assert parse_available_output_tokens_from_error(msg) == 65536
|
||||
|
||||
def test_dashscope_range_arbitrary_bound(self):
|
||||
msg = "Range of max_tokens should be [1, 8192]"
|
||||
assert parse_available_output_tokens_from_error(msg) == 8192
|
||||
|
||||
def test_dashscope_range_with_spaces(self):
|
||||
msg = "range of max_tokens should be [ 1 , 32768 ]"
|
||||
assert parse_available_output_tokens_from_error(msg) == 32768
|
||||
|
||||
|
||||
class TestIsOutputCapError:
|
||||
"""`is_output_cap_error` is the broader yes/no gate that keeps an
|
||||
output-cap 400 out of the compression death-loop even when we can't parse
|
||||
a number from the provider's wording (#55546)."""
|
||||
|
||||
def test_dashscope_is_output_cap(self):
|
||||
assert is_output_cap_error(
|
||||
"Range of max_tokens should be [1, 65536]"
|
||||
) is True
|
||||
|
||||
def test_unknown_numeric_max_tokens_cap_is_output_cap(self):
|
||||
# Provider we don't yet parse a number from, but clearly an output cap.
|
||||
assert is_output_cap_error("Invalid value: max_tokens should be <= 8192") is True
|
||||
|
||||
def test_anthropic_available_tokens_is_output_cap(self):
|
||||
assert is_output_cap_error(
|
||||
"max_tokens: 32768 > context_window: 200000 - "
|
||||
"input_tokens: 190000 = available_tokens: 10000"
|
||||
) is True
|
||||
|
||||
def test_real_input_overflow_is_not_output_cap(self):
|
||||
# Mentions max_tokens but the INPUT is the problem -> compression path.
|
||||
assert is_output_cap_error(
|
||||
"prompt is too long: 250000 tokens > 200000 max_tokens window"
|
||||
) is False
|
||||
|
||||
def test_gpt5_unsupported_param_is_not_output_cap(self):
|
||||
# format_error caught earlier; must NOT be treated as an output cap.
|
||||
assert is_output_cap_error(
|
||||
"Unsupported parameter: 'max_tokens' is not supported with this "
|
||||
"model. Use 'max_completion_tokens' instead."
|
||||
) is False
|
||||
|
||||
def test_unrelated_error_is_not_output_cap(self):
|
||||
assert is_output_cap_error("some unrelated 400 error") is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue