{"repo":"NousResearch/hermes-agent","public_id":"1552","display_label":"Truncation handling, chunking, output limit fixes","summary":"These items address how truncated or incomplete responses are detected, reported, and recovered from across the API server, agent, and gateway layers, alongside related fixes for message chunking in Telegram and output token limit management.","subsystem":"acp_adapter","counts":{"members":30,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":9,"merged_prs":6,"attempt_prs":14},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1644},"resolution":{"state":"partially_resolved","by":"pr-1644,pr-3426,pr-22775,pr-36705,pr-115825"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T17:03:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.055080459770114935},"fix_edges":[{"issue_number":1552,"pr_number":1644,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":1552,"pr_number":1646,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2706,"pr_number":3426,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22496,"pr_number":22775,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":26425,"pr_number":36705,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":68396,"pr_number":115825,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1552,"title":"[Bug]: send_message tool and cron delivery skip message chunking, causing delivery failures for long messages","url":null,"state":"closed"},{"kind":"issue","number":2706,"title":"Response truncated due to output length limit","url":null,"state":"closed"},{"kind":"issue","number":7237,"title":"[Bug]: Error: Response truncated due to output length limit","url":null,"state":"closed"},{"kind":"issue","number":20519,"title":"[Bug]: Response truncated due to output length limit on a new chat","url":null,"state":"closed"},{"kind":"issue","number":20571,"title":"Issue closed.","url":null,"state":"closed"},{"kind":"issue","number":20975,"title":"Truncated tool call: second retry should inject concise-args hint instead of giving up","url":null,"state":"open"},{"kind":"issue","number":22496,"title":"[Bug]: API server returns output-truncation failure as successful assistant message","url":null,"state":"closed"},{"kind":"issue","number":26425,"title":"[Bug]: Response truncated due to output length limit — still occurring after #7237 fix (re-opening closed issue)","url":null,"state":"closed"},{"kind":"issue","number":39254,"title":"Feature: Auto-Response Chunking for Telegram","url":null,"state":"closed"},{"kind":"issue","number":68396,"title":"validate_response() accepts router timeout shim text as valid assistant completion","url":null,"state":"closed"},{"kind":"pr","number":1557,"title":"fix(tools): chunk long messages in send_message_tool before dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1644","number":1644}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1644,"title":"fix(tools): chunk long messages in send_message_tool before dispatch (#1552)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"]},{"kind":"pr","number":1646,"title":"fix(tools): chunk long messages in send_message_tool before platform dispatch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","optional-skills/blockchain/base/SKILL.md","optional-skills/blockchain/base/scripts/base_client.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"]},{"kind":"pr","number":3426,"title":"fix(anthropic): use model-native output limits instead of hardcoded 16K","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","run_agent.py","tests/test_anthropic_adapter.py"]},{"kind":"pr","number":7242,"title":"fix(agent): add length-continuation support for anthropic_messages mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9691","number":9691},{"kind":"pr","value":"#12846","number":12846},{"kind":"pr","value":"#9691","number":9691},{"kind":"pr","value":"#12846","number":12846}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":21030,"title":"fix(agent): hint after repeated truncated tool calls","url":null,"state":"open","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21081,"title":"fix: Allow continuation retry for first message truncation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b0a5fe897456cd0bf8704c0abc7f13169dc6c897","number":0},{"kind":"pr","value":"#20519","number":20519}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21093,"title":"fix truncated tool-call retry guidance","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21030","number":21030}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":22500,"title":"fix(api_server): return 500 on failed/truncated runs instead of flattening to assistant content","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2124ad72a27d72dfdca0189f3e0e7b6213cb72ea","number":0},{"kind":"pr","value":"#22775","number":22775},{"kind":"pr","value":"#22775","number":22775}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":22517,"title":"fix(api-server): preserve truncation/failure semantics in chat completions (#22496)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22775","number":22775},{"kind":"pr","value":"#22874","number":22874}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22672,"title":"fix(api): set finish_reason and X-Hermes-* headers for failed/partial runs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22517","number":22517},{"kind":"pr","value":"#22496","number":22496},{"kind":"pr","value":"#22517","number":22517},{"kind":"pr","value":"#22517","number":22517}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":22775,"title":"fix(api-server): emit length/error finish_reason for truncation/failure","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -1206,10 +1206,49 @@ async def _compute_completion():\n                     status=500,\n                 )\n \n-        final_response = result.get(\"final_response\", \"\")\n-        if not final_response:\n-            final_response = result.get(\"error\", \"(No response generated)\")\n+        final_response = result.get(\"final_response\") or \"\"\n+        is_partial = bool(result.get(\"partial\"))\n+        is_failed = bool(result.get(\"failed\"))\n+        completed = bool(result.get(\"completed\", True))\n+        err_msg = result.get(\"error\")\n+\n+        # Decide finish_reason. OpenAI uses \"length\" for truncation, \"stop\"\n+        # for normal completion, and downstream SDKs accept \"error\" / custom\n+        # codes. See issue #22496.\n+        if is_partial and err_msg and \"truncat\" in err_msg.lower():\n+            finish_reason = \"length\"\n+        elif is_failed or (not completed and err_msg):\n+            finish_reason = \"error\"\n+        else:\n+            finish_reason = \"stop\"\n+\n+        response_headers = {\n+            \"X-Hermes-Session-Id\": result.get(\"session_id\", session_id),\n+        }\n+        if gateway_session_key:\n+            response_headers[\"X-Hermes-Session-Key\"] = gateway_session_key\n \n+        # Hard-fail path: no usable assistant text AND a real failure → 5xx\n+        # with OpenAI-style error envelope so SDK clients raise instead of\n+        # silently rendering the internal failure string as message.content.\n+        if not final_response and (is_failed or is_partial):\n+            err_body = _openai_error(\n+                err_msg or \"Agent run did not produce a response.\",\n+                err_type=\"server_error\",\n+                code=\"agent_incomplete\",\n+            )\n+            err_body[\"error\"][\"hermes\"] = {\n+                \"completed\": completed,\n+                \"partial\": is_partial,\n+                \"failed\": is_failed,\n+            }\n+            response_headers[\"X-Hermes-Completed\"] = \"false\"\n+            response_headers[\"X-Hermes-Partial\"] = \"true\" if is_partial else \"false\"\n+            return web.json_response(err_body, status=502, headers=response_headers)\n+\n+        # Soft-partial path: we have *some* text but the run did not complete\n+        # (e.g. truncation with partial buffered output). Still 200 but signal\n+        # truncation via finish_reason=\"length\" + Hermes-specific extras.\n         response_data = {\n             \"id\": completion_id,\n             \"object\": \"chat.completion\",","@@ -1222,7 +1261,7 @@ async def _compute_completion():\n                         \"role\": \"assistant\",\n                         \"content\": final_response,\n                     },\n-                    \"finish_reason\": \"stop\",\n+                    \"finish_reason\": finish_reason,\n                 }\n             ],\n             \"usage\": {","@@ -1231,12 +1270,19 @@ async def _compute_completion():\n                 \"total_tokens\": usage.get(\"total_tokens\", 0),\n             },\n         }\n+        if is_partial or is_failed or not completed:\n+            response_data[\"hermes\"] = {\n+                \"completed\": completed,\n+                \"partial\": is_partial,\n+                \"failed\": is_failed,\n+                \"error\": err_msg,\n+                \"error_code\": \"output_truncated\" if finish_reason == \"length\" else \"agent_error\",\n+            }\n+            response_headers[\"X-Hermes-Completed\"] = \"false\"\n+            response_headers[\"X-Hermes-Partial\"] = \"true\" if is_partial else \"false\"\n+            if err_msg:\n+                response_headers[\"X-Hermes-Error\"] = err_msg[:200]\n \n-        response_headers = {\n-            \"X-Hermes-Session-Id\": result.get(\"session_id\", session_id),\n-        }\n-        if gateway_session_key:\n-            response_headers[\"X-Hermes-Session-Key\"] = gateway_session_key\n         return web.json_response(response_data, headers=response_headers)\n \n     async def _write_sse_chat_completion("]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -2418,6 +2418,109 @@ async def test_no_truncation_keeps_full_history(self, adapter):\n         assert len(call_kwargs[\"conversation_history\"]) == 150\n \n \n+# ---------------------------------------------------------------------------\n+# Response-side truncation / failure handling (issue #22496)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestChatCompletionsAgentIncomplete:\n+    \"\"\"When the agent run yields a partial / failed result, the API server\n+    must NOT pretend it succeeded. Either signal truncation via\n+    finish_reason='length' (with the partial text), or 502 with an OpenAI\n+    error envelope (no usable text). Issue #22496.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_truncation_with_partial_text_uses_length_finish_reason(self, adapter):\n+        \"\"\"Partial text + truncation marker → finish_reason='length', 200 OK,\n+        plus hermes extras + headers.\"\"\"\n+        mock_result = {\n+            \"final_response\": \"Here is part one of the answer\",\n+            \"completed\": False,\n+            \"partial\": True,\n+            \"error\": \"Response truncated due to output length limit\",\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"tell me everything\"}]},\n+                )\n+            assert resp.status == 200\n+            data = await resp.json()\n+            assert data[\"choices\"][0][\"finish_reason\"] == \"length\"\n+            assert data[\"choices\"][0][\"message\"][\"content\"] == \"Here is part one of the answer\"\n+            assert data[\"hermes\"][\"partial\"] is True\n+            assert data[\"hermes\"][\"completed\"] is False\n+            assert data[\"hermes\"][\"error_code\"] == \"output_truncated\"\n+            assert resp.headers.get(\"X-Hermes-Completed\") == \"false\"\n+            assert resp.headers.get(\"X-Hermes-Partial\") == \"true\"\n+\n+    @pytest.mark.asyncio\n+    async def test_failure_with_no_text_returns_502_error_envelope(self, adapter):\n+        \"\"\"No usable assistant text + failure → 502 with OpenAI error envelope.\n+\n+        Pre-fix behavior: the failure string ('Response remained truncated...')\n+        was substituted into message.content with finish_reason='stop',\n+        making API clients think the agent had answered.\n+        \"\"\"\n+        mock_result = {\n+            \"final_response\": None,\n+            \"completed\": False,\n+            \"partial\": True,\n+            \"failed\": True,\n+            \"error\": \"Response remained truncated after 3 continuation attempts\",\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"x\"}]},\n+                )\n+            # Hard fail: SDK clients will raise on this status\n+            assert resp.status == 502\n+            data = await resp.json()\n+            assert data[\"error\"][\"code\"] == \"agent_incomplete\"\n+            assert \"truncated\" in data[\"error\"][\"message\"].lower()\n+            assert data[\"error\"][\"hermes\"][\"partial\"] is True\n+            assert data[\"error\"][\"hermes\"][\"failed\"] is True\n+            assert resp.headers.get(\"X-Hermes-Completed\") == \"false\"\n+\n+    @pytest.mark.asyncio\n+    async def test_normal_completion_unchanged(self, adapter):\n+        \"\"\"Sanity: a completed-True result still returns finish_reason='stop'\n+        and no hermes extras (preserves the existing happy-path contract).\"\"\"\n+        mock_result = {\n+            \"final_response\": \"All good.\",\n+            \"completed\": True,\n+            \"partial\": False,\n+            \"failed\": False,\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]},\n+                )\n+            assert resp.status == 200\n+            data = await resp.json()\n+            assert data[\"choices\"][0][\"finish_reason\"] == \"stop\"\n+            assert data[\"choices\"][0][\"message\"][\"content\"] == \"All good.\"\n+            assert \"hermes\" not in data\n+            assert \"X-Hermes-Completed\" not in resp.headers\n+\n+\n # ---------------------------------------------------------------------------\n # CORS\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":22874,"title":"fix(api-server): emit length/error finish_reason in SSE chat-completions stream","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b8b695e2cdef704756818c93e0e3633a786b5827","number":0}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":26427,"title":"fix(agent): preserve continuation output cap after truncation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36705","number":36705}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":27364,"title":"Retry truncated tool calls with concise-args hint","url":null,"state":"open","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":36705,"title":"fix(agent): stop reporting broken streams as output-length truncation","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","run_agent.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -1739,20 +1739,52 @@ def _stop_spinner():\n                     if agent.api_mode in {\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\"}:\n                         assistant_message = _trunc_msg\n                         if assistant_message is not None and _trunc_has_tool_calls:\n-                            if truncated_tool_call_retries < 1:\n+                            _is_stub_stall = (\n+                                getattr(response, \"id\", \"\") == PARTIAL_STREAM_STUB_ID\n+                            )\n+                            if truncated_tool_call_retries < 3:\n                                 truncated_tool_call_retries += 1\n-                                agent._buffer_vprint(\n-                                    f\"⚠️  Truncated tool call detected — retrying API call...\"\n-                                )\n+                                if _is_stub_stall:\n+                                    # The stream broke mid tool-call (network /\n+                                    # peer-closed connection), not a real output\n+                                    # cap — say so instead of \"max output tokens\".\n+                                    agent._buffer_vprint(\n+                                        f\"⚠️  Stream interrupted mid tool-call — \"\n+                                        f\"retrying ({truncated_tool_call_retries}/3)...\"\n+                                    )\n+                                else:\n+                                    agent._buffer_vprint(\n+                                        f\"⚠️  Truncated tool call detected — \"\n+                                        f\"retrying API call \"\n+                                        f\"({truncated_tool_call_retries}/3)...\"\n+                                    )\n+                                # Boost max_tokens on each retry so the model has\n+                                # more room to complete the tool-call JSON. A\n+                                # network stall doesn't need a bigger budget, but\n+                                # a genuine output-cap truncation does, and the\n+                                # boost is harmless for the stall case.\n+                                _tc_boost_base = agent.max_tokens if agent.max_tokens else 4096\n+                                _tc_boost = _tc_boost_base * (truncated_tool_call_retries + 1)\n+                                _tc_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)\n+                                if _tc_requested_cap is not None:\n+                                    _tc_boost = max(_tc_boost, _tc_requested_cap)\n+                                _tc_boost_cap = max(32768, _tc_requested_cap or 0)\n+                                agent._ephemeral_max_output_tokens = [redacted:secret-assignment] _tc_boost_cap)\n                                 # Don't append the broken response to messages;\n                                 # just re-run the same API call from the current\n                                 # message state, giving the model another chance.\n                                 continue\n                             agent._flush_status_buffer()\n-                            agent._vprint(\n-                                f\"{agent.log_prefix}⚠️  Truncated tool call response detected again — refusing to execute incomplete tool arguments.\",\n-                                force=True,\n-                            )\n+                            if _is_stub_stall:\n+                                agent._vprint(\n+                                    f\"{agent.log_prefix}⚠️  Stream kept dropping mid tool-call after 3 retries — the action was not executed.\",\n+                                    force=True,\n+                                )\n+                            else:\n+                                agent._vprint(\n+                                    f\"{agent.log_prefix}⚠️  Truncated tool call response detected again — refusing to execute incomplete tool arguments.\",\n+                                    force=True,\n+                                )\n                             agent._cleanup_task_resources(effective_task_id)\n                             agent._persist_session(messages, conversation_history)\n                             return {","@@ -1761,7 +1793,12 @@ def _stop_spinner():\n                                 \"api_calls\": api_call_count,\n                                 \"completed\": False,\n                                 \"partial\": True,\n-                                \"error\": \"Response truncated due to output length limit\",\n+                                \"error\": (\n+                                    \"Stream repeatedly dropped mid tool-call (network); \"\n+                                    \"the tool was not executed\"\n+                                    if _is_stub_stall\n+                                    else \"Response truncated due to output length limit\"\n+                                ),\n                             }\n \n                     # If we have prior messages, roll back to last complete state","@@ -3412,9 +3449,16 @@ def _stop_spinner():\n             # Progressively boost the output token budget on each retry.\n             # Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.\n             # Applies to all providers via _ephemeral_max_output_tokens.\n+            # If the original request already used a larger provider/model\n+            # default budget, keep that floor so continuation retries do\n+            # not accidentally downshift to a much smaller cap.\n             _boost_base = agent.max_tokens if agent.max_tokens else 4096\n             _boost = _boost_base * (length_continue_retries + 1)\n-            agent._ephemeral_max_output_tokens = [redacted:secret-assignment] 32768)\n+            _requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)\n+            if _requested_cap is not None:\n+                _boost = max(_boost, _requested_cap)\n+            _boost_cap = max(32768, _requested_cap or 0)\n+            agent._ephemeral_max_output_tokens = [redacted:secret-assignment] _boost_cap)\n             continue\n \n         # Guard: if all retries exhausted without a successful response"]},{"file":"run_agent.py","hunks":["@@ -1206,6 +1206,21 @@ def _max_tokens_param(self, value: int) -> dict:\n             return {\"max_completion_tokens\": value}\n         return {\"max_tokens\": value}\n \n+    @staticmethod\n+    def _requested_output_cap_from_api_kwargs(api_kwargs: Any) -> Optional[int]:\n+        \"\"\"Extract the outgoing response token cap from a prepared request.\"\"\"\n+        if not isinstance(api_kwargs, dict):\n+            return None\n+        for key in (\"max_output_tokens\", \"max_completion_tokens\", \"max_tokens\"):\n+            raw = api_kwargs.get(key)\n+            try:\n+                value = int(raw)\n+            except (TypeError, ValueError):\n+                continue\n+            if value > 0:\n+                return value\n+        return None\n+\n     def _has_content_after_think_block(self, content: str) -> bool:\n         \"\"\"\n         Check if content has actual text after any reasoning/thinking blocks."]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3699,6 +3699,36 @@ def test_length_finish_reason_requests_continuation(self, agent):\n         assert second_call_messages[-1][\"role\"] == \"user\"\n         assert \"truncated by the output length limit\" in second_call_messages[-1][\"content\"]\n \n+    def test_length_continuation_preserves_large_provider_default_output_cap(self, agent):\n+        \"\"\"Continuation retries must not shrink a higher provider default cap.\"\"\"\n+        self._setup_agent(agent)\n+        agent.max_tokens = None\n+        requested_caps = []\n+\n+        def _fake_build_api_kwargs(api_messages):\n+            ephemeral = getattr(agent, \"_ephemeral_max_output_tokens\", None)\n+            if ephemeral is not None:\n+                agent._ephemeral_max_output_tokens = None\n+            cap = ephemeral if ephemeral is not None else 65536\n+            requested_caps.append(cap)\n+            return {\"model\": agent.model, \"messages\": api_messages, \"max_tokens\": cap}\n+\n+        first = _mock_response(content=\"Part 1 \", finish_reason=\"length\")\n+        second = _mock_response(content=\"Part 2\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.side_effect = [first, second]\n+\n+        with (\n+            patch.object(agent, \"_build_api_kwargs\", side_effect=_fake_build_api_kwargs),\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert result[\"completed\"] is True\n+        assert result[\"final_response\"] == \"Part 1 Part 2\"\n+        assert requested_caps == [65536, 65536]\n+\n     def test_ollama_glm_stop_after_tools_without_terminal_boundary_requests_continuation(self, agent):\n         \"\"\"Ollama-hosted GLM responses can misreport truncated output as stop.\"\"\"\n         self._setup_agent(agent)","@@ -3877,7 +3907,8 @@ def test_length_with_tool_calls_returns_partial_without_executing_tools(self, ag\n \n     def test_truncated_tool_call_retries_once_before_refusing(self, agent):\n         \"\"\"When tool call args are truncated, the agent retries the API call\n-        once. If the retry succeeds (valid JSON args), tool execution proceeds.\"\"\"\n+        (up to 3 times). If a retry succeeds (valid JSON args), tool execution\n+        proceeds.\"\"\"\n         self._setup_agent(agent)\n         agent.valid_tool_names.add(\"write_file\")\n         bad_tc = _mock_tool_call(","@@ -3914,6 +3945,48 @@ def test_truncated_tool_call_retries_once_before_refusing(self, agent):\n         mock_hfc.assert_called_once()\n         assert result[\"final_response\"] == \"Done!\"\n \n+    def test_stub_stall_mid_tool_call_recovers_within_3_retries(self, agent):\n+        \"\"\"A network stream stall mid tool-call (PARTIAL_STREAM_STUB_ID) must\n+        retry up to 3 times rather than hard-failing after one — and recover\n+        if a retry produces a complete tool call. Regression for the false\n+        'model hit max output tokens' on Opus when the stream simply dropped.\"\"\"\n+        from hermes_constants import PARTIAL_STREAM_STUB_ID\n+\n+        self._setup_agent(agent)\n+        agent.valid_tool_names.add(\"write_file\")\n+        bad_tc = _mock_tool_call(\n+            name=\"write_file\",\n+            arguments='{\"path\":\"report.md\",\"content\":\"partial',\n+            call_id=\"c1\",\n+        )\n+        # Two consecutive stub-stall responses, then a clean tool call.\n+        stall1 = _mock_response(content=\"\", finish_reason=\"length\", tool_calls=[bad_tc])\n+        stall1.id = PARTIAL_STREAM_STUB_ID\n+        stall2 = _mock_response(content=\"\", finish_reason=\"length\", tool_calls=[bad_tc])\n+        stall2.id = PARTIAL_STREAM_STUB_ID\n+        good_tc = _mock_tool_call(\n+            name=\"write_file\",\n+            arguments='{\"path\":\"report.md\",\"content\":\"full content\"}',\n+            call_id=\"c2\",\n+        )\n+        good_resp = _mock_response(content=\"\", finish_reason=\"stop\", tool_calls=[good_tc])\n+        final_resp = _mock_response(content=\"Done!\", finish_reason=\"stop\")\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value='{\"success\":true}') as mock_hfc,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            agent.client.chat.completions.create.side_effect = [\n+                stall1, stall2, good_resp, final_resp,\n+            ]\n+            result = agent.run_conversation(\"write the report\")\n+\n+        # Recovered on the 3rd attempt instead of refusing after the 1st.\n+        mock_hfc.assert_called_once()\n+        assert result[\"final_response\"] == \"Done!\"\n+\n     def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent):\n         \"\"\"When a router rewrites finish_reason from 'length' to 'tool_calls',\n         truncated JSON arguments should still be detected and refused rather\n"]}]},{"kind":"pr","number":68433,"title":"fix(agent): reject router timeout shim responses","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9f7928ecdea7","number":0},{"kind":"pr","value":"#115825","number":115825},{"kind":"pr","value":"#68396","number":68396},{"kind":"pr","value":"#68433","number":68433}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68572,"title":"fix(agent): reject router timeout shim in validate_response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68580","number":68580}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":68580,"title":"fix(agent): reject router timeout shim in validate_response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68433","number":68433}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":115825,"title":"fix(agent): router 'Connect timeout' HTTP-200 shim is retried instead of returned as the answer, on every path (#68396, salvage #68433)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/chat_completion_helpers.py","agent/transports/chat_completions.py","tests/agent/test_router_timeout_shim.py","tests/agent/transports/test_chat_completions.py","website/docs/user-guide/features/fallback-providers.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}