diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index b3cd4952f29..a3677d61ccc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 9430a98bfb1..e2ca04399ab 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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). diff --git a/tests/test_output_cap_parsing.py b/tests/test_output_cap_parsing.py index fdb436585e9..ddeee6045dd 100644 --- a/tests/test_output_cap_parsing.py +++ b/tests/test_output_cap_parsing.py @@ -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