{"repo":"NousResearch/hermes-agent","public_id":"55546","display_label":"max_tokens cap errors causing infinite compression loops","summary":"These issues and fixes address cases where provider errors about token limits (400s from DashScope/Qwen and similar) or affordability (402) are misclassified, triggering repeated context compression death-loops or dropped requests instead of recoverable retries.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55570},"resolution":{"state":"resolved","by":"pr-64009"},"freshness":{"state":"verified","last_verified_at":"2026-07-13T22:28:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":55546,"pr_number":55570,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":55546,"pr_number":64009,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":55546,"title":"[Bug]: max_tokens over provider cap death-loops into context compression when the provider's 400 wording isn't recognized (e.g. DashScope/Qwen)","url":null,"state":"closed"},{"kind":"pr","number":55561,"title":"fix(agent): recognize DashScope max_tokens range error to prevent compression death-loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c7e0bdef9","number":0},{"kind":"pr","value":"#55570","number":55570}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55570,"title":"fix(agent): stop over-cap max_tokens 400s from death-looping into compression","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","agent/model_metadata.py","tests/test_output_cap_parsing.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -52,6 +52,7 @@\n     estimate_messages_tokens_rough,\n     estimate_request_tokens_rough,\n     get_context_length_from_provider_error,\n+    is_output_cap_error,\n     parse_available_output_tokens_from_error,\n     save_context_length,\n )","@@ -3213,6 +3214,45 @@ def _perform_api_call(next_api_kwargs):\n                         _retry.restart_with_compressed_messages = True\n                         break\n \n+                    # The error is output-cap-shaped (about max_tokens being\n+                    # too large) but the provider's wording didn't let us parse\n+                    # the available output budget.  Compression CANNOT help here\n+                    # — the input already fits; the call fails deterministically\n+                    # on the oversized max_tokens.  Routing it into compression\n+                    # re-sends the same max_tokens, gets the identical 400, and\n+                    # death-loops until \"cannot compress further\" (#55546).\n+                    # Fail fast with an actionable message instead of looping.\n+                    if is_output_cap_error(error_msg):\n+                        agent._flush_status_buffer()\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}❌ The provider rejected the request because \"\n+                            f\"max_tokens exceeds its output cap for this model.\",\n+                            force=True,\n+                        )\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}   💡 Lower model.max_tokens in your config.yaml to \"\n+                            f\"at or below the model's max-output limit. \"\n+                            f\"(This is an output-cap error, not a context overflow — \"\n+                            f\"compression cannot fix it.)\",\n+                            force=True,\n+                        )\n+                        logger.error(\n+                            f\"{agent.log_prefix}Output-cap error not routed into compression \"\n+                            f\"(max_tokens over provider cap): {error_msg[:200]}\"\n+                        )\n+                        agent._persist_session(messages, conversation_history)\n+                        return {\n+                            \"messages\": messages,\n+                            \"completed\": False,\n+                            \"api_calls\": api_call_count,\n+                            \"error\": (\n+                                \"max_tokens exceeds the provider's output cap for this model. \"\n+                                \"Lower model.max_tokens in config.yaml.\"\n+                            ),\n+                            \"partial\": True,\n+                            \"failed\": True,\n+                        }\n+\n                     # Error is about the INPUT being too large.  Only reduce\n                     # context_length when the provider explicitly reports the\n                     # real lower limit.  If the provider only says \"input"]},{"file":"agent/model_metadata.py","hunks":["@@ -1075,10 +1075,29 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:\n         \"maximum context length\" in error_lower\n         and \"requested\" in error_lower\n         and \"output tokens\" in error_lower\n+    ) or (\n+        # DashScope / Alibaba Cloud (Qwen) phrasing.  The provider rejects an\n+        # over-cap output request with a bounded range whose upper bound IS the\n+        # real max-output cap, e.g.\n+        #   \"Range of max_tokens should be [1, 65536]\"\n+        # The input itself fits — this is purely an output-cap error, so reduce\n+        # max_tokens and retry; do NOT compress.\n+        \"range of max_tokens should be\" in error_lower\n     )\n     if not is_output_cap_error:\n         return None\n \n+    # DashScope / Alibaba range form: \"Range of max_tokens should be [1, 65536]\".\n+    # The upper bound is the available output cap.\n+    _m_range = re.search(\n+        r'range of max_tokens should be\\s*\\[\\s*\\d+\\s*,\\s*(\\d+)\\s*\\]',\n+        error_lower,\n+    )\n+    if _m_range:\n+        _cap = int(_m_range.group(1))\n+        if _cap >= 1:\n+            return _cap\n+\n     # Extract the available_tokens figure.\n     # Anthropic format: \"… = available_tokens: 10000\"\n     patterns = [","@@ -1125,6 +1144,70 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:\n     return None\n \n \n+def is_output_cap_error(error_msg: str) -> bool:\n+    \"\"\"Return True if a 400 is about the OUTPUT cap (max_tokens) being too large.\n+\n+    This is the broader sibling of :func:`parse_available_output_tokens_from_error`:\n+    that function only returns a number when it can extract the available output\n+    budget from a *known* provider phrasing.  This one answers the cheaper\n+    yes/no question — \"is this an output-cap error at all?\" — across providers\n+    whose exact wording we may not yet parse a number from.\n+\n+    Why this matters: an output-cap 400 is deterministic (every retry with the\n+    same ``max_tokens`` gets the identical rejection).  If such an error is\n+    misclassified as a context-overflow it gets routed into the compression\n+    loop, the compressor re-issues the call with the same oversized\n+    ``max_tokens``, the provider rejects it identically, and the session\n+    death-loops until \"cannot compress further\" (issue #55546, DashScope/Qwen:\n+    \"Range of max_tokens should be [1, 65536]\").  Compression cannot help an\n+    output-cap error — the input already fits.\n+\n+    The signal: the error talks about ``max_tokens`` (or its aliases) as a\n+    cap/range/limit, and does NOT talk about the INPUT/prompt/context window\n+    being too long.  When both are present we defer to the context-overflow\n+    path (a real input overflow can also mention max_tokens).\n+    \"\"\"\n+    error_lower = error_msg.lower()\n+\n+    mentions_output_param = (\n+        \"max_tokens\" in error_lower\n+        or \"max_output_tokens\" in error_lower\n+        or \"max_completion_tokens\" in error_lower\n+    )\n+    if not mentions_output_param:\n+        return False\n+\n+    # Phrasing that signals the OUTPUT cap specifically is the problem.\n+    output_cap_signal = (\n+        \"range of max_tokens should be\" in error_lower      # DashScope / Alibaba\n+        or \"available_tokens\" in error_lower                # Anthropic\n+        or \"available tokens\" in error_lower\n+        or (\"in the output\" in error_lower                  # OpenRouter / Nous\n+            and \"maximum context length\" in error_lower)\n+        or (\"requested\" in error_lower                      # LM Studio / llama.cpp\n+            and \"output tokens\" in error_lower)\n+        or \"should be\" in error_lower                       # generic \"max_tokens should be <= N\"\n+        or \"less than or equal\" in error_lower\n+        or \"must be\" in error_lower\n+    )\n+    if not output_cap_signal:\n+        return False\n+\n+    # If the error ALSO clearly describes an oversized INPUT, it is a genuine\n+    # context overflow that happens to mention max_tokens — let the\n+    # context-overflow path handle it (it can compress the input).\n+    input_overflow_signal = (\n+        \"prompt is too long\" in error_lower\n+        or \"prompt too long\" in error_lower\n+        or \"input is too long\" in error_lower\n+        or \"input token\" in error_lower\n+        or \"prompt length\" in error_lower\n+        or \"prompt contains\" in error_lower\n+        or \"reduce the length\" in error_lower\n+    )\n+    return not input_overflow_signal\n+\n+\n def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:\n     \"\"\"Return True if *candidate_id* (from server) matches *lookup_model* (configured).\n "]},{"file":"tests/test_output_cap_parsing.py","hunks":["@@ -1,5 +1,8 @@\n import pytest\n-from agent.model_metadata import parse_available_output_tokens_from_error\n+from agent.model_metadata import (\n+    is_output_cap_error,\n+    parse_available_output_tokens_from_error,\n+)\n \n \n class TestParseOpenRouterOutputCap:","@@ -62,3 +65,58 @@ def test_char_based_no_room_returns_none(self):\n         msg = (\"maximum context length is 1000 tokens. However, you requested \"\n                \"1000 output tokens and your prompt contains 9000 characters.\")\n         assert parse_available_output_tokens_from_error(msg) is None\n+\n+\n+class TestParseDashScopeOutputCap:\n+    \"\"\"DashScope / Alibaba Cloud (Qwen) reject an over-cap output request with\n+    a bounded range whose upper bound is the real max-output cap (#55546).\"\"\"\n+\n+    def test_dashscope_range_format(self):\n+        msg = (\"HTTP 400: InternalError.Algo.InvalidParameter: \"\n+               \"Range of max_tokens should be [1, 65536]\")\n+        assert parse_available_output_tokens_from_error(msg) == 65536\n+\n+    def test_dashscope_range_arbitrary_bound(self):\n+        msg = \"Range of max_tokens should be [1, 8192]\"\n+        assert parse_available_output_tokens_from_error(msg) == 8192\n+\n+    def test_dashscope_range_with_spaces(self):\n+        msg = \"range of max_tokens should be [ 1 , 32768 ]\"\n+        assert parse_available_output_tokens_from_error(msg) == 32768\n+\n+\n+class TestIsOutputCapError:\n+    \"\"\"`is_output_cap_error` is the broader yes/no gate that keeps an\n+    output-cap 400 out of the compression death-loop even when we can't parse\n+    a number from the provider's wording (#55546).\"\"\"\n+\n+    def test_dashscope_is_output_cap(self):\n+        assert is_output_cap_error(\n+            \"Range of max_tokens should be [1, 65536]\"\n+        ) is True\n+\n+    def test_unknown_numeric_max_tokens_cap_is_output_cap(self):\n+        # Provider we don't yet parse a number from, but clearly an output cap.\n+        assert is_output_cap_error(\"Invalid value: max_tokens should be <= 8192\") is True\n+\n+    def test_anthropic_available_tokens_is_output_cap(self):\n+        assert is_output_cap_error(\n+            \"max_tokens: 32768 > context_window: 200000 - \"\n+            \"input_tokens: [redacted:secret-assignment] = available_tokens: 10000\"\n+        ) is True\n+\n+    def test_real_input_overflow_is_not_output_cap(self):\n+        # Mentions max_tokens but the INPUT is the problem -> compression path.\n+        assert is_output_cap_error(\n+            \"prompt is too long: 250000 tokens > 200000 max_tokens window\"\n+        ) is False\n+\n+    def test_gpt5_unsupported_param_is_not_output_cap(self):\n+        # format_error caught earlier; must NOT be treated as an output cap.\n+        assert is_output_cap_error(\n+            \"Unsupported parameter: 'max_tokens' is not supported with this \"\n+            \"model. Use 'max_completion_tokens' instead.\"\n+        ) is False\n+\n+    def test_unrelated_error_is_not_output_cap(self):\n+        assert is_output_cap_error(\"some unrelated 400 error\") is False\n"]}]},{"kind":"pr","number":63862,"title":"fix: make output-cap retries respect current request capacity","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64009","number":64009},{"kind":"pr","value":"#55546","number":55546}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":64009,"title":"fix: make output-cap retries respect current request capacity","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","scripts/release.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -3061,14 +3061,24 @@ def _perform_api_call(next_api_kwargs):\n                 # (``/new``), switch to a larger-context model, or reduce\n                 # attachments.  Forced compaction via ``/compress``\n                 # (``force=True``) is unaffected — it never reaches this loop.\n+                #\n+                # Output-cap errors (max_tokens too large) are NOT input\n+                # overflow — the recovery is a max_tokens-only retry that\n+                # does not require compression.  Exempt them from this guard\n+                # so the retry still fires even when compression is disabled.\n                 _overflow_reasons = {\n                     FailoverReason.long_context_tier,\n                     FailoverReason.payload_too_large,\n                     FailoverReason.context_overflow,\n                 }\n+                _is_output_cap_error = (\n+                    is_output_cap_error(error_msg)\n+                    or parse_available_output_tokens_from_error(error_msg) is not None\n+                )\n                 if (\n                     classified.reason in _overflow_reasons\n                     and not getattr(agent, \"compression_enabled\", True)\n+                    and not _is_output_cap_error\n                 ):\n                     agent._flush_status_buffer()\n                     agent._vprint(","@@ -3472,15 +3482,33 @@ def _perform_api_call(next_api_kwargs):\n                     #       context_length = total window (input + output combined).\n                     available_out = parse_available_output_tokens_from_error(error_msg)\n                     if available_out is not None:\n-                        # Error is purely about the output cap being too large.\n-                        # Cap output to the available space and retry without\n-                        # touching context_length or triggering compression.\n-                        safe_out = max(1, available_out - 64)  # small safety margin\n+                        # This is an output-cap error, not input overflow.\n+                        # The provider's available_tokens is the authoritative\n+                        # cap for the failed request, so keep it as an upper\n+                        # bound.  Also estimate the current API request shape\n+                        # (system prompt, injected context, tool schemas) because\n+                        # Hermes may add API-only content not present in persisted\n+                        # messages.  Use the smaller budget and apply a small\n+                        # safety margin.  Do not alter context_length.\n+                        request_input_estimate = estimate_request_tokens_rough(\n+                            api_messages, tools=agent.tools or None,\n+                        )\n+                        local_available_out = old_ctx - request_input_estimate\n+                        if local_available_out > 0:\n+                            safe_out = max(1, min(available_out, local_available_out) - 64)\n+                        else:\n+                            # The rough local estimate can overshoot the real\n+                            # request size.  Fall back to the provider-reported\n+                            # budget, which is authoritative for the failed\n+                            # request.\n+                            safe_out = max(1, available_out - 64)\n                         agent._ephemeral_max_output_tokens = [redacted:secret-assignment]\n                         agent._buffer_vprint(\n                             f\"⚠️  Output cap too large for current prompt — \"\n                             f\"retrying with max_tokens=[redacted:secret-assignment] \"\n-                            f\"(available_tokens=[redacted:secret-assignment] context_length unchanged at {old_ctx:,})\"\n+                            f\"(provider_available={available_out:,}, \"\n+                            f\"estimated_request_tokens=[redacted:secret-assignment] \"\n+                            f\"context_length unchanged at {old_ctx:,})\"\n                         )\n                         # Still count against compression_attempts so we don't\n                         # loop forever if the error keeps recurring."]},{"file":"scripts/release.py","hunks":["@@ -136,6 +136,7 @@\n     \"[redacted:email]\": \"testingbuddies24\",  # PR #43192 salvage (strip orphan think-tag close tags in progressive gateway stream so a bare </think> whose open was dropped upstream can't leak to the user)\n     \"[redacted:email]\": \"Lazymonter\",  # PR #42914 salvage (retry launchd bootstrap after bootout on EIO for install/start instead of degrading to detached)\n     \"[redacted:email]\": \"WXBR\",  # PR #46183 salvage (persist recovered final_response at the finalize_turn chokepoint so recovery-path breaks don't drop the delivered assistant row)\n+    \"[redacted:email]\": \"dmabry\",  # PR #63862 salvage (output-cap retry: use provider available_tokens + request estimate; exempt parseable vLLM/LM Studio errors from compression-disabled guard)\n     \"[redacted:email]\": \"SahilRakhaiya05\",  # PR #44073 salvage (fail-closed gateway/external-surface hardening: own-policy defaults, open-policy startup guard, profile-aware multiplex authz, API-server auth, execute_code per-session RPC token)\n     \"[redacted:email]\": \"itenev\",  # PR #22753 salvage (asyncify model-context resolution in gateway message path so blocking requests.get can't starve Discord heartbeats)\n     \"[redacted:email]\": \"arthurzhang\",  # PR #34718 salvage (redact Slack App-Level xapp- tokens in agent/redact.py + gateway/run.py)"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -5355,6 +5355,281 @@ def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch):\n             \"_record_task_failure should not be called outside kanban mode\"\n         )\n \n+    # ── Output-cap retry: safe_out uses provider available_out + request estimate ──\n+\n+    def test_output_cap_retry_uses_provider_available_out(self, agent):\n+        \"\"\"run_conversation retries an output-cap error with max_tokens <=\n+        available_out - 64, and does NOT halve context_length or trigger\n+        compression.\n+        \"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = True\n+        agent.context_compressor.context_length = 200_000\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        error_msg = (\n+            \"max_tokens: 65536 > context_window: 200000 \"\n+            \"- input_tokens: [redacted:secret-assignment] = available_tokens: 1000\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+        assert second_call[\"max_tokens\"] <= 936\n+        assert agent.context_compressor.context_length == 200_000\n+        mock_compress.assert_not_called()\n+\n+    def test_output_cap_retry_with_large_api_only_content(self, agent):\n+        \"\"\"When a large system prompt makes api_messages huge while persisted\n+        messages stay tiny, the retry cap must still respect provider\n+        available_tokens — not blow up to the full context window.\n+        \"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = True\n+        agent.context_compressor.context_length = 200_000\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        # Huge API-only system prompt; persisted messages are tiny.\n+        agent._cached_system_prompt = \"S\" * 796_000\n+\n+        error_msg = (\n+            \"max_tokens: 65536 > context_window: 200000 \"\n+            \"- input_tokens: [redacted:secret-assignment] = available_tokens: 1000\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+        # The current branch (messages-only estimate) would send max_tokens\n+        # near 199927 — this test fails on it.\n+        assert second_call[\"max_tokens\"] <= 936\n+        assert agent.context_compressor.context_length == 200_000\n+        mock_compress.assert_not_called()\n+\n+    def test_output_cap_retry_request_pressure_lower_bound(self, agent):\n+        \"\"\"When the provider reports a large available_tokens but local request\n+        pressure leaves less room, the retry cap is the smaller of the two.\n+        \"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = True\n+        agent.context_compressor.context_length = 200_000\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        # A large API-only system prompt so the local estimate is the binding\n+        # constraint, not the provider's available_tokens.\n+        agent._cached_system_prompt = \"S\" * 796_000\n+\n+        error_msg = (\n+            \"max_tokens: 65536 > context_window: 200000 \"\n+            \"- input_tokens: [redacted:secret-assignment] = available_tokens: 50000\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        first_call = agent.client.chat.completions.create.call_args_list[0].kwargs\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+\n+        # Verify the local estimate is actually the lower bound.\n+        from agent.model_metadata import estimate_request_tokens_rough\n+        estimated_request = estimate_request_tokens_rough(\n+            first_call[\"messages\"], tools=agent.tools or None,\n+        )\n+        local_available = 200_000 - estimated_request\n+        expected_cap = max(1, min(50_000, local_available) - 64)\n+        assert local_available < 50_000\n+        assert second_call[\"max_tokens\"] == expected_cap\n+        assert agent.context_compressor.context_length == 200_000\n+        mock_compress.assert_not_called()\n+\n+    def test_output_cap_retry_safety_floor_at_one(self, agent):\n+        \"\"\"When provider available_tokens is 1, the retry cap is floored at 1.\"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = True\n+        agent.context_compressor.context_length = 200_000\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        error_msg = (\n+            \"max_tokens: 65536 > context_window: 200000 \"\n+            \"- input_tokens: [redacted:secret-assignment] = available_tokens: 1\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+        assert second_call[\"max_tokens\"] == 1\n+        assert agent.context_compressor.context_length == 200_000\n+        mock_compress.assert_not_called()\n+\n+    def test_output_cap_retry_with_compression_disabled(self, agent):\n+        \"\"\"Output-cap retry must still work when compression.enabled is false.\n+        The recovery is a max_tokens-only retry — it does not require compression,\n+        so the compression-disabled guard must not block it.\n+        \"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = False\n+        agent.context_compressor.context_length = 200_000\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        error_msg = (\n+            \"max_tokens: 65536 > context_window: 200000 \"\n+            \"- input_tokens: [redacted:secret-assignment] = available_tokens: 1000\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        # Two API calls: the failed one and the retried one.\n+        assert len(agent.client.chat.completions.create.call_args_list) == 2\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+        assert result.get(\"compaction_disabled\") is None\n+        assert second_call[\"max_tokens\"] <= 936\n+        assert agent.context_compressor.context_length == 200_000\n+        mock_compress.assert_not_called()\n+\n+    def test_output_cap_retry_with_compression_disabled_vllm_format(self, agent):\n+        \"\"\"vLLM/LM Studio error messages contain 'prompt contains ... input\n+        tokens' which is_output_cap_error() treats as an input-overflow signal\n+        (returns False).  But parse_available_output_tokens_from_error() CAN\n+        extract a valid available_tokens from them.  The compression-disabled\n+        guard must exempt these too — otherwise users on vLLM/LM Studio with\n+        compression off get a terminal failure instead of a max-tokens retry.\n+        \"\"\"\n+        self._setup_agent(agent)\n+        agent.api_mode = \"chat_completions\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"some/model\"\n+        agent.max_tokens = [redacted:secret-assignment]\n+        agent.compression_enabled = False\n+        agent.context_compressor.context_length = 131_072\n+        agent.context_compressor.should_compress = MagicMock(return_value=False)\n+\n+        # vLLM-format error (from tests/test_output_cap_parsing.py)\n+        error_msg = (\n+            \"This model's maximum context length is 131072 tokens. \"\n+            \"However, you requested 1024 output tokens and your prompt \"\n+            \"contains at least 65537 input tokens, for a total of at least \"\n+            \"66561 tokens.\"\n+        )\n+        exc = Exception(error_msg)\n+        exc.status_code = 400\n+        exc.code = 400\n+\n+        ok_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [exc, ok_resp]\n+\n+        mock_compress = MagicMock()\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+            patch.object(agent.context_compressor, \"update_model\"),\n+            patch.object(agent, \"_compress_context\", mock_compress),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert len(agent.client.chat.completions.create.call_args_list) == 2\n+        second_call = agent.client.chat.completions.create.call_args_list[1].kwargs\n+        assert result[\"completed\"] is True\n+        assert result.get(\"compaction_disabled\") is None\n+        # parse_available_output_tokens_from_error returns 65535 for this message\n+        assert second_call[\"max_tokens\"] <= 65471  # 65535 - 64\n+        assert agent.context_compressor.context_length == 131_072\n+        mock_compress.assert_not_called()\n+\n \n class TestHookPayloadSanitizesSimpleNamespace:\n     \"\"\"Regression: ``_hook_jsonable`` referenced ``SimpleNamespace`` without\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}