{"repo":"NousResearch/hermes-agent","public_id":"30963","display_label":"Mid-tool-call stream failure causes retry loop","summary":"These issues and fixes address a scenario where a stream dying mid-tool-call, combined with partial-stream-stub handling, creates an unrecoverable or silent retry loop. The work focuses on routing partial-stream-stub correctly through length continuation to handle this case.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30998},"resolution":{"state":"resolved","by":"pr-30998,pr-32012"},"freshness":{"state":"verified","last_verified_at":"2026-05-25T12:13:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":30963,"pr_number":30998,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":31998,"pr_number":32012,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":30963,"title":"[Bug]: Partial Stream Misclassified as Clean Completion Causes Premature Loop Exit","url":null,"state":"closed"},{"kind":"issue","number":31998,"title":"Stale stream + partial-stream-stub creates unrecoverable retry loop on large tool calls","url":null,"state":"closed"},{"kind":"pr","number":30988,"title":"fix(streaming): use finish_reason=length for partial stream stub","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30998","number":30998}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30998,"title":"fix(streaming): treat partial-stream stub as length truncation, not clean stop","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/conversation_loop.py","tests/run_agent/test_partial_stream_finish_reason.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -2076,8 +2076,21 @@ def _call():\n             # Streaming failed AFTER some tokens were already delivered to\n             # the platform.  Re-raising would let the outer retry loop make\n             # a new API call, creating a duplicate message.  Return a\n-            # partial \"stop\" response instead so the outer loop treats this\n-            # turn as complete (no retry, no fallback).\n+            # partial response stub instead and let the outer loop decide:\n+            #\n+            #   - text-only partials → finish_reason=\"length\" so the\n+            #     conversation loop persists the partial assistant content\n+            #     and asks the model to continue from where the stream\n+            #     died (issue #30963: partial stop misclassified as a\n+            #     clean completion was exiting the loop with budget\n+            #     remaining and an unfinished goal).\n+            #\n+            #   - partial mid-tool-call → finish_reason=\"stop\" stays.\n+            #     The user-visible warning we append says \"Ask me to\n+            #     retry if you want to continue\", so the agent should\n+            #     hand control back rather than auto-retry a tool call\n+            #     that may have side-effects.\n+            #\n             # Recover whatever content was already streamed to the user.\n             # _current_streamed_assistant_text accumulates text fired\n             # through _fire_stream_delta, so it has exactly what the","@@ -2115,14 +2128,17 @@ def _call():\n                     \"of text; surfaced warning to user: %s\",\n                     _partial_names, len(_partial_text or \"\"), result[\"error\"],\n                 )\n+                _stub_finish_reason = \"stop\"\n             else:\n                 logger.warning(\n-                    \"Partial stream delivered before error; returning stub \"\n-                    \"response with %s chars of recovered content to prevent \"\n-                    \"duplicate messages: %s\",\n+                    \"Partial stream delivered before error; returning \"\n+                    \"length-truncated stub with %s chars of recovered \"\n+                    \"content so the loop can continue from where the \"\n+                    \"stream died: %s\",\n                     len(_partial_text or \"\"),\n                     result[\"error\"],\n                 )\n+                _stub_finish_reason = \"length\"\n             _stub_msg = SimpleNamespace(\n                 role=\"assistant\", content=_partial_text, tool_calls=None,\n                 reasoning_content=None,","@@ -2131,7 +2147,7 @@ def _call():\n                 id=\"partial-stream-stub\",\n                 model=getattr(agent, \"model\", \"unknown\"),\n                 choices=[SimpleNamespace(\n-                    index=0, message=_stub_msg, finish_reason=\"stop\",\n+                    index=0, message=_stub_msg, finish_reason=_stub_finish_reason,\n                 )],\n                 usage=None,\n             )"]},{"file":"agent/conversation_loop.py","hunks":["@@ -1414,7 +1414,18 @@ def _stop_spinner():\n                         finish_reason = \"length\"\n \n                 if finish_reason == \"length\":\n-                    agent._vprint(f\"{agent.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens\", force=True)\n+                    if getattr(response, \"id\", \"\") == \"partial-stream-stub\":\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}⚠️  Stream interrupted by network error \"\n+                            f\"(finish_reason='length' on partial-stream-stub)\",\n+                            force=True,\n+                        )\n+                    else:\n+                        agent._vprint(\n+                            f\"{agent.log_prefix}⚠️  Response truncated \"\n+                            f\"(finish_reason='length') - model hit max output tokens\",\n+                            force=True,\n+                        )\n \n                     # Normalize the truncated response to a single OpenAI-style\n                     # message shape so text-continuation and tool-call retry","@@ -1507,17 +1518,40 @@ def _stop_spinner():\n                                 truncated_response_parts.append(assistant_message.content)\n \n                             if length_continue_retries < 3:\n-                                agent._vprint(\n-                                    f\"{agent.log_prefix}↻ Requesting continuation \"\n-                                    f\"({length_continue_retries}/3)...\"\n+                                # Distinguish a real output-token truncation\n+                                # from a partial-stream-stub network error\n+                                # (#30963).  Same continuation machinery,\n+                                # but the prompt has to tell the truth or\n+                                # the model goes off rails (\"I wasn't\n+                                # truncated, I'm done\").\n+                                _is_partial_stream_stub = (\n+                                    getattr(response, \"id\", \"\") == \"partial-stream-stub\"\n                                 )\n-                                continue_msg = {\n-                                    \"role\": \"user\",\n-                                    \"content\": (\n+                                if _is_partial_stream_stub:\n+                                    agent._vprint(\n+                                        f\"{agent.log_prefix}↻ Stream interrupted — \"\n+                                        f\"requesting continuation \"\n+                                        f\"({length_continue_retries}/3)...\"\n+                                    )\n+                                    _continue_content = (\n+                                        \"[System: The previous response was cut off by a \"\n+                                        \"network error mid-stream. Continue exactly where \"\n+                                        \"you left off. Do not restart or repeat prior text. \"\n+                                        \"Finish the answer directly.]\"\n+                                    )\n+                                else:\n+                                    agent._vprint(\n+                                        f\"{agent.log_prefix}↻ Requesting continuation \"\n+                                        f\"({length_continue_retries}/3)...\"\n+                                    )\n+                                    _continue_content = (\n                                         \"[System: Your previous response was truncated by the output \"\n                                         \"length limit. Continue exactly where you left off. Do not \"\n                                         \"restart or repeat prior text. Finish the answer directly.]\"\n-                                    ),\n+                                    )\n+                                continue_msg = {\n+                                    \"role\": \"user\",\n+                                    \"content\": _continue_content,\n                                 }\n                                 messages.append(continue_msg)\n                                 agent._session_messages = messages"]},{"file":"tests/run_agent/test_partial_stream_finish_reason.py","hunks":["@@ -0,0 +1,258 @@\n+\"\"\"Regression tests for issue #30963 — partial-stream stub finish_reason.\n+\n+Pins the contract:\n+\n+- text-only partial stream → stub.finish_reason == \"length\" so the\n+  conversation loop's existing length-continuation path can keep the\n+  agent moving against an unfinished goal.\n+- partial mid-tool-call → stub.finish_reason == \"stop\" so the loop\n+  hands control back to the user (matches the user-visible warning\n+  \"Ask me to retry if you want to continue\").\n+- conversation_loop's length-continuation prompt distinguishes a real\n+  output-length truncation from a partial-stream-stub network error\n+  via response.id.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ── Helpers (mirrors test_streaming.py) ────────────────────────────────────\n+\n+def _make_stream_chunk(content=None, tool_calls=None, finish_reason=None):\n+    delta = SimpleNamespace(\n+        content=content, tool_calls=tool_calls,\n+        reasoning_content=None, reasoning=None,\n+    )\n+    choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)\n+    return SimpleNamespace(choices=[choice], model=None, usage=None)\n+\n+\n+def _make_tool_call_delta(index=0, tc_id=None, name=None, arguments=None):\n+    func = SimpleNamespace(name=name, arguments=arguments)\n+    return SimpleNamespace(index=index, id=tc_id, function=func)\n+\n+\n+def _make_agent():\n+    from run_agent import AIAgent\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"https://example.com/v1\",\n+        model=\"test/model\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+    agent.api_mode = \"chat_completions\"\n+    agent._interrupt_requested = False\n+    return agent\n+\n+\n+# ── Stub finish_reason ────────────────────────────────────────────────────\n+\n+class TestPartialStreamStubFinishReason:\n+    \"\"\"The stub returned by interruptible_streaming_api_call when the\n+    upstream connection dies mid-flight.\"\"\"\n+\n+    @patch(\"run_agent.AIAgent._create_request_openai_client\")\n+    @patch(\"run_agent.AIAgent._close_request_openai_client\")\n+    def test_text_only_partial_returns_length(self, _mock_close, mock_create, monkeypatch):\n+        \"\"\"#30963: text-only partials must classify as length so the loop\n+        keeps continuing instead of exiting with budget remaining.\"\"\"\n+\n+        def _stalling_stream():\n+            yield _make_stream_chunk(content=\"Here's my answer so far\")\n+            raise RuntimeError(\"simulated upstream stall\")\n+\n+        mock_client = MagicMock()\n+        mock_client.chat.completions.create.side_effect = lambda *a, **kw: _stalling_stream()\n+        mock_create.return_value = mock_client\n+\n+        agent = _make_agent()\n+        agent._current_streamed_assistant_text = \"Here's my answer so far\"\n+\n+        monkeypatch.setenv(\"HERMES_STREAM_RETRIES\", \"0\")\n+        response = agent._interruptible_streaming_api_call({})\n+\n+        assert response.id == \"partial-stream-stub\"\n+        assert response.choices[0].finish_reason == \"length\", (\n+            \"Text-only partial streams must use finish_reason=length so the \"\n+            \"conversation loop continues from where the network died \"\n+            \"(issue #30963).\"\n+        )\n+        assert response.choices[0].message.content == \"Here's my answer so far\"\n+        assert response.choices[0].message.tool_calls is None\n+\n+    @patch(\"run_agent.AIAgent._create_request_openai_client\")\n+    @patch(\"run_agent.AIAgent._close_request_openai_client\")\n+    def test_partial_tool_call_keeps_stop(self, _mock_close, mock_create, monkeypatch):\n+        \"\"\"Mid-tool-call partials keep finish_reason=stop on purpose — the\n+        warning text asks the user to drive the retry, not the agent.\"\"\"\n+\n+        def _stalling_stream():\n+            yield _make_stream_chunk(content=\"Let me write the audit: \")\n+            yield _make_stream_chunk(tool_calls=[\n+                _make_tool_call_delta(index=0, tc_id=\"call_1\", name=\"write_file\"),\n+            ])\n+            yield _make_stream_chunk(tool_calls=[\n+                _make_tool_call_delta(index=0, arguments='{\"path\": \"/tmp/x\", '),\n+            ])\n+            raise RuntimeError(\"simulated upstream stall\")\n+\n+        mock_client = MagicMock()\n+        mock_client.chat.completions.create.side_effect = lambda *a, **kw: _stalling_stream()\n+        mock_create.return_value = mock_client\n+\n+        agent = _make_agent()\n+        agent._fire_stream_delta = lambda text: None\n+        agent._current_streamed_assistant_text = \"Let me write the audit: \"\n+\n+        monkeypatch.setenv(\"HERMES_STREAM_RETRIES\", \"0\")\n+        response = agent._interruptible_streaming_api_call({})\n+\n+        assert response.id == \"partial-stream-stub\"\n+        assert response.choices[0].finish_reason == \"stop\", (\n+            \"Partial mid-tool-call must keep finish_reason=stop — the warning \"\n+            \"appended to content asks the user to retry, so the agent must \"\n+            \"not auto-replay a tool call with possible side-effects.\"\n+        )\n+        content = response.choices[0].message.content or \"\"\n+        assert \"Stream stalled mid tool-call\" in content\n+        assert \"write_file\" in content\n+\n+\n+# ── Length-continuation prompt branching ──────────────────────────────────\n+\n+class TestLengthContinuationPromptBranching:\n+    \"\"\"When finish_reason=length, the continuation prompt that reaches the\n+    model has to tell the truth: real truncation vs. network interruption.\n+    Lying (\"you were truncated\") on a partial-stream stub leads the model\n+    to no-op (\"I wasn't truncated, I'm done\"), defeating recovery.\"\"\"\n+\n+    def _simulate_branch(self, response_id: str) -> str:\n+        \"\"\"Return the continuation prompt text the loop would inject for\n+        a `finish_reason=length` response with the given id. Mirrors the\n+        exact branch in agent/conversation_loop.py.\"\"\"\n+        response = SimpleNamespace(id=response_id)\n+        if getattr(response, \"id\", \"\") == \"partial-stream-stub\":\n+            return (\n+                \"[System: The previous response was cut off by a \"\n+                \"network error mid-stream. Continue exactly where \"\n+                \"you left off. Do not restart or repeat prior text. \"\n+                \"Finish the answer directly.]\"\n+            )\n+        return (\n+            \"[System: Your previous response was truncated by the output \"\n+            \"length limit. Continue exactly where you left off. Do not \"\n+            \"restart or repeat prior text. Finish the answer directly.]\"\n+        )\n+\n+    def test_partial_stream_stub_uses_network_prompt(self):\n+        prompt = self._simulate_branch(\"partial-stream-stub\")\n+        assert \"network error mid-stream\" in prompt\n+        assert \"output length limit\" not in prompt\n+\n+    def test_real_truncation_uses_length_prompt(self):\n+        prompt = self._simulate_branch(\"chatcmpl-abc123\")\n+        assert \"output length limit\" in prompt\n+        assert \"network error\" not in prompt\n+\n+    def test_no_id_falls_through_to_length_prompt(self):\n+        prompt = self._simulate_branch(\"\")\n+        assert \"output length limit\" in prompt\n+\n+\n+# ── Integration: live conversation loop ───────────────────────────────────\n+\n[redacted:email]()\n+def loop_agent():\n+    \"\"\"AIAgent with a mocked OpenAI client (mirrors test_run_agent's fixture)\n+    so we can stage a stub + continuation pair on .chat.completions.create.\"\"\"\n+    from run_agent import AIAgent\n+    with (\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        a = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        a.client = MagicMock()\n+        a._cached_system_prompt = \"You are helpful.\"\n+        a._use_prompt_caching = False\n+        a.tool_delay = 0\n+        a.compression_enabled = False\n+        a.save_trajectories = False\n+        return a\n+\n+\n+class TestConversationLoopPartialStreamContinuation:\n+    \"\"\"End-to-end: a partial-stream stub feeds the loop and the loop\n+    asks for continuation instead of exiting with finish_reason=stop.\"\"\"\n+\n+    def test_partial_stream_stub_does_not_exit_loop_immediately(self, loop_agent):\n+        \"\"\"The stub from chat_completion_helpers used to exit the loop with\n+        text_response(finish_reason=stop). Now finish_reason=length routes\n+        through length_continue_retries — the loop persists the partial\n+        content and asks the model to continue.\"\"\"\n+\n+        from tests.run_agent.test_run_agent import _mock_response, _mock_assistant_msg\n+\n+        # First API call: the partial-stream stub (length on partial-stream-stub id).\n+        partial_stub = SimpleNamespace(\n+            id=\"partial-stream-stub\",\n+            model=\"test/model\",\n+            choices=[SimpleNamespace(\n+                index=0,\n+                message=_mock_assistant_msg(content=\"The first half of \"),\n+                finish_reason=\"length\",\n+            )],\n+            usage=None,\n+        )\n+        # Second API call: model continues with the rest, clean stop.\n+        continuation = _mock_response(\n+            content=\"the answer is forty-two.\", finish_reason=\"stop\",\n+        )\n+\n+        loop_agent.client.chat.completions.create.side_effect = [\n+            partial_stub, continuation,\n+        ]\n+\n+        with (\n+            patch.object(loop_agent, \"_persist_session\"),\n+            patch.object(loop_agent, \"_save_trajectory\"),\n+            patch.object(loop_agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = loop_agent.run_conversation(\"ask me something\")\n+\n+        # The loop made TWO API calls (stub + continuation), not one.\n+        assert loop_agent.client.chat.completions.create.call_count == 2, (\n+            \"Partial-stream-stub must trigger a continuation API call, not \"\n+            \"exit the loop after one call.\"\n+        )\n+        # The continuation prompt the loop appended must be the network-error\n+        # variant, not the \"output length limit\" lie — otherwise the model\n+        # no-ops with \"I wasn't truncated, I'm done.\"\n+        # We assert it indirectly by inspecting the second-call kwargs.\n+        second_call_kwargs = loop_agent.client.chat.completions.create.call_args_list[1]\n+        msgs = second_call_kwargs.kwargs.get(\"messages\") or second_call_kwargs.args[0].get(\"messages\")\n+        last_user = next(\n+            (m for m in reversed(msgs) if m.get(\"role\") == \"user\"), None,\n+        )\n+        assert last_user is not None\n+        assert \"network error mid-stream\" in (last_user.get(\"content\") or \"\"), (\n+            \"Continuation prompt for partial-stream-stub must mention the \"\n+            \"network error, not the 'output length limit'.\"\n+        )\n+\n+        # And the final response stitches both halves together.\n+        assert \"first half of\" in result[\"final_response\"]\n+        assert \"forty-two\" in result[\"final_response\"]\n"]}]},{"kind":"pr","number":32012,"title":"fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","agent/conversation_loop.py","hermes_constants.py","tests/run_agent/test_partial_stream_finish_reason.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -34,6 +34,7 @@\n from urllib.parse import urlparse, parse_qs, urlunparse\n \n from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout\n+from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH\n from agent.error_classifier import classify_api_error, FailoverReason\n from agent.model_metadata import is_local_endpoint\n from agent.message_sanitization import (","@@ -2151,37 +2152,15 @@ def _call():\n         if deltas_were_sent[\"yes\"]:\n             # Streaming failed AFTER some tokens were already delivered to\n             # the platform.  Re-raising would let the outer retry loop make\n-            # a new API call, creating a duplicate message.  Return a\n-            # partial response stub instead and let the outer loop decide:\n-            #\n-            #   - text-only partials → finish_reason=\"length\" so the\n-            #     conversation loop persists the partial assistant content\n-            #     and asks the model to continue from where the stream\n-            #     died (issue #30963: partial stop misclassified as a\n-            #     clean completion was exiting the loop with budget\n-            #     remaining and an unfinished goal).\n-            #\n-            #   - partial mid-tool-call → finish_reason=\"stop\" stays.\n-            #     The user-visible warning we append says \"Ask me to\n-            #     retry if you want to continue\", so the agent should\n-            #     hand control back rather than auto-retry a tool call\n-            #     that may have side-effects.\n-            #\n-            # Recover whatever content was already streamed to the user.\n-            # _current_streamed_assistant_text accumulates text fired\n-            # through _fire_stream_delta, so it has exactly what the\n-            # user saw before the connection died.\n+            # Return a partial response stub with finish_reason=\"length\"\n+            # so the conversation loop's continuation machinery fires.\n+            # tool_calls=None prevents auto-execution of incomplete calls.\n             _partial_text = (\n                 getattr(agent, \"_current_streamed_assistant_text\", \"\") or \"\"\n             ).strip() or None\n \n-            # If the stream died while the model was emitting a tool call,\n-            # the stub below will silently set `tool_calls=None` and the\n-            # agent loop will treat the turn as complete — the attempted\n-            # action is lost with no user-facing signal.  Append a\n-            # human-visible warning to the stub content so (a) the user\n-            # knows something failed, and (b) the next turn's model sees\n-            # in conversation history what was attempted and can retry.\n+            # Append a user-visible warning if tool calls were dropped so\n+            # the user and model both know what was attempted.\n             _partial_names = list(result.get(\"partial_tool_names\") or [])\n             if _partial_names:\n                 _name_str = \", \".join(_partial_names[:3])","@@ -2193,8 +2172,7 @@ def _call():\n                     f\"Ask me to retry if you want to continue.\"\n                 )\n                 _partial_text = (_partial_text or \"\") + _warn\n-                # Also fire as a streaming delta so the user sees it now\n-                # instead of only in the persisted transcript.\n+                # Fire as streaming delta so the user sees it immediately.\n                 try:\n                     agent._fire_stream_delta(_warn)\n                 except Exception:","@@ -2204,7 +2182,7 @@ def _call():\n                     \"of text; surfaced warning to user: %s\",\n                     _partial_names, len(_partial_text or \"\"), result[\"error\"],\n                 )\n-                _stub_finish_reason = \"stop\"\n+                _stub_finish_reason = FINISH_REASON_LENGTH\n             else:\n                 logger.warning(\n                     \"Partial stream delivered before error; returning \"","@@ -2214,18 +2192,19 @@ def _call():\n                     len(_partial_text or \"\"),\n                     result[\"error\"],\n                 )\n-                _stub_finish_reason = \"length\"\n+                _stub_finish_reason = FINISH_REASON_LENGTH\n             _stub_msg = SimpleNamespace(\n                 role=\"assistant\", content=_partial_text, tool_calls=None,\n                 reasoning_content=None,\n             )\n             return SimpleNamespace(\n-                id=\"partial-stream-stub\",\n+                id=PARTIAL_STREAM_STUB_ID,\n                 model=getattr(agent, \"model\", \"unknown\"),\n                 choices=[SimpleNamespace(\n                     index=0, message=_stub_msg, finish_reason=_stub_finish_reason,\n                 )],\n                 usage=None,\n+                _dropped_tool_names=_partial_names or None,\n             )\n         raise result[\"error\"]\n     return result[\"response\"]"]},{"file":"agent/conversation_loop.py","hunks":["@@ -65,7 +65,7 @@\n from agent.retry_utils import jittered_backoff\n from agent.trajectory import has_incomplete_scratchpad\n from agent.usage_pricing import estimate_usage_cost, normalize_usage\n-from hermes_constants import display_hermes_home as _dhh_fn\n+from hermes_constants import display_hermes_home as _dhh_fn, PARTIAL_STREAM_STUB_ID\n from hermes_logging import set_session_context\n from tools.schema_sanitizer import strip_pattern_and_format\n from tools.skill_provenance import set_current_write_origin","@@ -229,6 +229,37 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)\n             )\n \n \n+def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:\n+    if is_partial_stub and dropped_tools:\n+        tool_list = \", \".join(dropped_tools[:3])\n+        return (\n+            \"[System: Your previous tool call \"\n+            f\"({tool_list}) was too large and \"\n+            \"the stream timed out before it \"\n+            \"could be delivered. Do NOT retry \"\n+            \"the same tool call with the same \"\n+            \"large content. Instead, break the \"\n+            \"content into multiple smaller tool \"\n+            \"calls (e.g. use multiple patch calls \"\n+            \"or write smaller files). Each tool \"\n+            \"call's arguments must be under ~8K \"\n+            \"tokens to avoid stream timeouts.]\"\n+        )\n+    elif is_partial_stub:\n+        return (\n+            \"[System: The previous response was cut off by a \"\n+            \"network error mid-stream. Continue exactly where \"\n+            \"you left off. Do not restart or repeat prior text. \"\n+            \"Finish the answer directly.]\"\n+        )\n+    else:\n+        return (\n+            \"[System: Your previous response was truncated by the output \"\n+            \"length limit. Continue exactly where you left off. Do not \"\n+            \"restart or repeat prior text. Finish the answer directly.]\"\n+        )\n+\n+\n def run_conversation(\n     agent,\n     user_message: str,","@@ -1414,7 +1445,7 @@ def _stop_spinner():\n                         finish_reason = \"length\"\n \n                 if finish_reason == \"length\":\n-                    if getattr(response, \"id\", \"\") == \"partial-stream-stub\":\n+                    if getattr(response, \"id\", \"\") == PARTIAL_STREAM_STUB_ID:\n                         agent._vprint(\n                             f\"{agent.log_prefix}⚠️  Stream interrupted by network error \"\n                             f\"(finish_reason='length' on partial-stream-stub)\",","@@ -1518,37 +1549,36 @@ def _stop_spinner():\n                                 truncated_response_parts.append(assistant_message.content)\n \n                             if length_continue_retries < 3:\n-                                # Distinguish a real output-token truncation\n-                                # from a partial-stream-stub network error\n-                                # (#30963).  Same continuation machinery,\n-                                # but the prompt has to tell the truth or\n-                                # the model goes off rails (\"I wasn't\n-                                # truncated, I'm done\").\n                                 _is_partial_stream_stub = (\n-                                    getattr(response, \"id\", \"\") == \"partial-stream-stub\"\n+                                    getattr(response, \"id\", \"\") == PARTIAL_STREAM_STUB_ID\n                                 )\n-                                if _is_partial_stream_stub:\n+                                _dropped_tools = getattr(\n+                                    response, \"_dropped_tool_names\", None\n+                                )\n+\n+                                if _is_partial_stream_stub and _dropped_tools:\n+                                    _tool_list = \", \".join(_dropped_tools[:3])\n+                                    agent._vprint(\n+                                        f\"{agent.log_prefix}↻ Stream interrupted mid \"\n+                                        f\"tool-call ({_tool_list}) — requesting \"\n+                                        f\"chunked retry \"\n+                                        f\"({length_continue_retries}/3)...\"\n+                                    )\n+                                elif _is_partial_stream_stub:\n                                     agent._vprint(\n                                         f\"{agent.log_prefix}↻ Stream interrupted — \"\n                                         f\"requesting continuation \"\n                                         f\"({length_continue_retries}/3)...\"\n                                     )\n-                                    _continue_content = (\n-                                        \"[System: The previous response was cut off by a \"\n-                                        \"network error mid-stream. Continue exactly where \"\n-                                        \"you left off. Do not restart or repeat prior text. \"\n-                                        \"Finish the answer directly.]\"\n-                                    )\n                                 else:\n                                     agent._vprint(\n                                         f\"{agent.log_prefix}↻ Requesting continuation \"\n                                         f\"({length_continue_retries}/3)...\"\n                                     )\n-                                    _continue_content = (\n-                                        \"[System: Your previous response was truncated by the output \"\n-                                        \"length limit. Continue exactly where you left off. Do not \"\n-                                        \"restart or repeat prior text. Finish the answer directly.]\"\n-                                    )\n+\n+                                _continue_content = _get_continuation_prompt(\n+                                    _is_partial_stream_stub, _dropped_tools\n+                                )\n                                 continue_msg = {\n                                     \"role\": \"user\",\n                                     \"content\": _continue_content,"]},{"file":"hermes_constants.py","hunks":["@@ -432,6 +432,14 @@ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):\n     socket.getaddrinfo = _ipv4_getaddrinfo  # type: ignore[assignment]\n \n \n+# ─── Streaming Response Constants ────────────────────────────────────────────\n+\n+# Response ID for partial stream stubs used during error recovery\n+PARTIAL_STREAM_STUB_ID = \"partial-stream-stub\"\n+\n+FINISH_REASON_LENGTH = \"length\"\n+\n+\n OPENROUTER_BASE_URL = \"https://openrouter.ai/api/v1\"\n OPENROUTER_MODELS_URL = f\"{OPENROUTER_BASE_URL}/models\"\n "]},{"file":"tests/run_agent/test_partial_stream_finish_reason.py","hunks":["@@ -5,9 +5,9 @@\n - text-only partial stream → stub.finish_reason == \"length\" so the\n   conversation loop's existing length-continuation path can keep the\n   agent moving against an unfinished goal.\n-- partial mid-tool-call → stub.finish_reason == \"stop\" so the loop\n-  hands control back to the user (matches the user-visible warning\n-  \"Ask me to retry if you want to continue\").\n+- partial mid-tool-call → stub.finish_reason == \"length\" so the loop\n+  triggers continuation machinery with targeted chunking guidance\n+  instead of ending the turn immediately.\n - conversation_loop's length-continuation prompt distinguishes a real\n   output-length truncation from a partial-stream-stub network error\n   via response.id.","@@ -20,6 +20,9 @@\n \n import pytest\n \n+from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH\n+from agent.conversation_loop import _get_continuation_prompt\n+\n \n # ── Helpers (mirrors test_streaming.py) ────────────────────────────────────\n ","@@ -78,8 +81,8 @@ def _stalling_stream():\n         monkeypatch.setenv(\"HERMES_STREAM_RETRIES\", \"0\")\n         response = agent._interruptible_streaming_api_call({})\n \n-        assert response.id == \"partial-stream-stub\"\n-        assert response.choices[0].finish_reason == \"length\", (\n+        assert response.id == PARTIAL_STREAM_STUB_ID\n+        assert response.choices[0].finish_reason == FINISH_REASON_LENGTH, (\n             \"Text-only partial streams must use finish_reason=length so the \"\n             \"conversation loop continues from where the network died \"\n             \"(issue #30963).\"","@@ -89,9 +92,11 @@ def _stalling_stream():\n \n     @patch(\"run_agent.AIAgent._create_request_openai_client\")\n     @patch(\"run_agent.AIAgent._close_request_openai_client\")\n-    def test_partial_tool_call_keeps_stop(self, _mock_close, mock_create, monkeypatch):\n-        \"\"\"Mid-tool-call partials keep finish_reason=stop on purpose — the\n-        warning text asks the user to drive the retry, not the agent.\"\"\"\n+    def test_partial_tool_call_uses_length(self, _mock_close, mock_create, monkeypatch):\n+        \"\"\"Mid-tool-call partials now use finish_reason=length so the\n+        conversation loop's continuation machinery fires — bounded 3-retry\n+        with guidance to break output into smaller chunks (#31998).\n+        tool_calls=None is preserved, so no tool auto-executes.\"\"\"\n \n         def _stalling_stream():\n             yield _make_stream_chunk(content=\"Let me write the audit: \")","@@ -114,12 +119,18 @@ def _stalling_stream():\n         monkeypatch.setenv(\"HERMES_STREAM_RETRIES\", \"0\")\n         response = agent._interruptible_streaming_api_call({})\n \n-        assert response.id == \"partial-stream-stub\"\n-        assert response.choices[0].finish_reason == \"stop\", (\n-            \"Partial mid-tool-call must keep finish_reason=stop — the warning \"\n-            \"appended to content asks the user to retry, so the agent must \"\n-            \"not auto-replay a tool call with possible side-effects.\"\n+        assert response.id == PARTIAL_STREAM_STUB_ID\n+        assert response.choices[0].finish_reason == FINISH_REASON_LENGTH, (\n+            \"Partial mid-tool-call must use finish_reason=length so the \"\n+            \"continuation machinery fires instead of ending the turn \"\n+            \"immediately (#31998).\"\n+        )\n+        assert response.choices[0].message.tool_calls is None, (\n+            \"tool_calls must remain None (no auto-execution of side-effectful \"\n+            \"tool calls).\"\n         )\n+        # The stub should carry dropped tool names for continuation prompt\n+        assert getattr(response, \"_dropped_tool_names\", None) == [\"write_file\"]\n         content = response.choices[0].message.content or \"\"\n         assert \"Stream stalled mid tool-call\" in content\n         assert \"write_file\" in content","@@ -129,30 +140,17 @@ def _stalling_stream():\n \n class TestLengthContinuationPromptBranching:\n     \"\"\"When finish_reason=length, the continuation prompt that reaches the\n-    model has to tell the truth: real truncation vs. network interruption.\n-    Lying (\"you were truncated\") on a partial-stream stub leads the model\n-    to no-op (\"I wasn't truncated, I'm done\"), defeating recovery.\"\"\"\n+    model has to tell the truth: real truncation vs. network interruption\n+    vs. dropped tool call (#31998).  Three distinct prompts now exist.\"\"\"\n \n-    def _simulate_branch(self, response_id: str) -> str:\n+    def _simulate_branch(self, response_id: str, dropped_tools=None) -> str:\n         \"\"\"Return the continuation prompt text the loop would inject for\n-        a `finish_reason=length` response with the given id. Mirrors the\n-        exact branch in agent/conversation_loop.py.\"\"\"\n-        response = SimpleNamespace(id=response_id)\n-        if getattr(response, \"id\", \"\") == \"partial-stream-stub\":\n-            return (\n-                \"[System: The previous response was cut off by a \"\n-                \"network error mid-stream. Continue exactly where \"\n-                \"you left off. Do not restart or repeat prior text. \"\n-                \"Finish the answer directly.]\"\n-            )\n-        return (\n-            \"[System: Your previous response was truncated by the output \"\n-            \"length limit. Continue exactly where you left off. Do not \"\n-            \"restart or repeat prior text. Finish the answer directly.]\"\n-        )\n+        a `finish_reason=length` response with the given id.\"\"\"\n+        is_partial = response_id == PARTIAL_STREAM_STUB_ID\n+        return _get_continuation_prompt(is_partial, dropped_tools)\n \n     def test_partial_stream_stub_uses_network_prompt(self):\n-        prompt = self._simulate_branch(\"partial-stream-stub\")\n+        prompt = self._simulate_branch(PARTIAL_STREAM_STUB_ID)\n         assert \"network error mid-stream\" in prompt\n         assert \"output length limit\" not in prompt\n ","@@ -165,6 +163,19 @@ def test_no_id_falls_through_to_length_prompt(self):\n         prompt = self._simulate_branch(\"\")\n         assert \"output length limit\" in prompt\n \n+    def test_dropped_tool_call_uses_chunking_prompt(self):\n+        \"\"\"When the stub dropped a tool call, the continuation prompt\n+        must guide the model to break its output into smaller chunks\n+        instead of retrying the same large tool call (#31998).\"\"\"\n+        prompt = self._simulate_branch(\n+            PARTIAL_STREAM_STUB_ID, dropped_tools=[\"write_file\"],\n+        )\n+        assert \"too large\" in prompt\n+        assert \"break\" in prompt.lower()\n+        assert \"write_file\" in prompt\n+        assert \"network error\" not in prompt\n+        assert \"output length limit\" not in prompt\n+\n \n # ── Integration: live conversation loop ───────────────────────────────────\n ","@@ -208,12 +219,12 @@ def test_partial_stream_stub_does_not_exit_loop_immediately(self, loop_agent):\n \n         # First API call: the partial-stream stub (length on partial-stream-stub id).\n         partial_stub = SimpleNamespace(\n-            id=\"partial-stream-stub\",\n+            id=PARTIAL_STREAM_STUB_ID,\n             model=\"test/model\",\n             choices=[SimpleNamespace(\n                 index=0,\n                 message=_mock_assistant_msg(content=\"The first half of \"),\n-                finish_reason=\"length\",\n+                finish_reason=FINISH_REASON_LENGTH,\n             )],\n             usage=None,\n         )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}