# NousResearch/hermes-agent · Mid-tool-call stream failure causes retry loop

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #30998. Verify it addresses your case.
- **Recommended:** read PR #30998 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-30998,pr-32012`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.34`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #30963: \[Bug\]: Partial Stream Misclassified as Clean Completion Causes Premature Loop Exit
- #31998: Stale stream + partial-stream-stub creates unrecoverable retry loop on large tool calls

## Prior & competing PRs

- **#30998: fix(streaming): treat partial-stream stub as length truncation, not clean stop**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/chat_completion_helpers.py`, `agent/conversation_loop.py`, `tests/run_agent/test_partial_stream_finish_reason.py`
  - fix hunks:
  - `agent/chat_completion_helpers.py`

    ```diff
    @@ -2076,8 +2076,21 @@ def _call():
                 # Streaming failed AFTER some tokens were already delivered to
                 # the platform.  Re-raising would let the outer retry loop make
                 # a new API call, creating a duplicate message.  Return a
    -            # partial "stop" response instead so the outer loop treats this
    -            # turn as complete (no retry, no fallback).
    +            # partial response stub instead and let the outer loop decide:
    +            #
    +            #   - text-only partials → finish_reason="length" so the
    +            #     conversation loop persists the partial assistant content
    +            #     and asks the model to continue from where the stream
    +            #     died (issue #30963: partial stop misclassified as a
    +            #     clean completion was exiting the loop with budget
    +            #     remaining and an unfinished goal).
    +            #
    +            #   - partial mid-tool-call → finish_reason="stop" stays.
    +            #     The user-visible warning we append says "Ask me to
    +            #     retry if you want to continue", so the agent should
    +            #     hand control back rather than auto-retry a tool call
    +            #     that may have side-effects.
    +            #
                 # Recover whatever content was already streamed to the user.
                 # _current_streamed_assistant_text accumulates text fired
                 # through _fire_stream_delta, so it has exactly what the
    ```

    ```diff
    @@ -2115,14 +2128,17 @@ def _call():
                         "of text; surfaced warning to user: %s",
                         _partial_names, len(_partial_text or ""), result["error"],
                     )
    +                _stub_finish_reason = "stop"
                 else:
                     logger.warning(
    -                    "Partial stream delivered before error; returning stub "
    -                    "response with %s chars of recovered content to prevent "
    -                    "duplicate messages: %s",
    +                    "Partial stream delivered before error; returning "
    +                    "length-truncated stub with %s chars of recovered "
    +                    "content so the loop can continue from where the "
    +                    "stream died: %s",
                         len(_partial_text or ""),
                         result["error"],
                     )
    +                _stub_finish_reason = "length"
                 _stub_msg = SimpleNamespace(
                     role="assistant", content=_partial_text, tool_calls=None,
                     reasoning_content=None,
    ```

    ```diff
    @@ -2131,7 +2147,7 @@ def _call():
                     id="partial-stream-stub",
                     model=getattr(agent, "model", "unknown"),
                     choices=[SimpleNamespace(
    -                    index=0, message=_stub_msg, finish_reason="stop",
    +                    index=0, message=_stub_msg, finish_reason=_stub_finish_reason,
                     )],
                     usage=None,
                 )
    ```
  - `agent/conversation_loop.py`

    ```diff
    @@ -1414,7 +1414,18 @@ def _stop_spinner():
                             finish_reason = "length"
     
                     if finish_reason == "length":
    -                    agent._vprint(f"{agent.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens", force=True)
    +                    if getattr(response, "id", "") == "partial-stream-stub":
    +                        agent._vprint(
    +                            f"{agent.log_prefix}⚠️  Stream interrupted by network error "
    +                            f"(finish_reason='length' on partial-stream-stub)",
    +                            force=True,
    +                        )
    +                    else:
    +                        agent._vprint(
    +                            f"{agent.log_prefix}⚠️  Response truncated "
    +                            f"(finish_reason='length') - model hit max output tokens",
    +                            force=True,
    +                        )
     
                         # Normalize the truncated response to a single OpenAI-style
                         # message shape so text-continuation and tool-call retry
    ```

    ```diff
    @@ -1507,17 +1518,40 @@ def _stop_spinner():
                                     truncated_response_parts.append(assistant_message.content)
     
                                 if length_continue_retries < 3:
    -                                agent._vprint(
    -                                    f"{agent.log_prefix}↻ Requesting continuation "
    -                                    f"({length_continue_retries}/3)..."
    +                                # Distinguish a real output-token truncation
    +                                # from a partial-stream-stub network error
    +                                # (#30963).  Same continuation machinery,
    +                                # but the prompt has to tell the truth or
    +                                # the model goes off rails ("I wasn't
    +                                # truncated, I'm done").
    +                                _is_partial_stream_stub = (
    +                                    getattr(response, "id", "") == "partial-stream-stub"
                                     )
    -                                continue_msg = {
    -                                    "role": "user",
    -                                    "content": (
    +                                if _is_partial_stream_stub:
    +                                    agent._vprint(
    +                                        f"{agent.log_prefix}↻ Stream interrupted — "
    +                                        f"requesting continuation "
    +                                        f"({length_continue_retries}/3)..."
    +                                    )
    +                                    _continue_content = (
    +                                        "[System: The previous response was cut off by a "
    +                                        "network error mid-stream. Continue exactly where "
    +                                        "you left off. Do not restart or repeat prior text. "
    +                                        "Finish the answer directly.]"
    +                                    )
    +                                else:
    +                                    agent._vprint(
    +                                        f"{agent.log_prefix}↻ Requesting continuation "
    +                                        f"({length_continue_retries}/3)..."
    +                                    )
    +                                    _continue_content = (
                                             "[System: Your previous response was truncated by the output "
                                             "length limit. Continue exactly where you left off. Do not "
                                             "restart or repeat prior text. Finish the answer directly.]"
    -                                    ),
    +                                    )
    +                                continue_msg = {
    +                                    "role": "user",
    +                                    "content": _continue_content,
                                     }
                                     messages.append(continue_msg)
                                     agent._session_messages = messages
    ```
  - `tests/run_agent/test_partial_stream_finish_reason.py`

    ```diff
    @@ -0,0 +1,258 @@
    +"""Regression tests for issue #30963 — partial-stream stub finish_reason.
    +
    +Pins the contract:
    +
    +- text-only partial stream → stub.finish_reason == "length" so the
    +  conversation loop's existing length-continuation path can keep the
    +  agent moving against an unfinished goal.
    +- partial mid-tool-call → stub.finish_reason == "stop" so the loop
    +  hands control back to the user (matches the user-visible warning
    +  "Ask me to retry if you want to continue").
    +- conversation_loop's length-continuation prompt distinguishes a real
    +  output-length truncation from a partial-stream-stub network error
    +  via response.id.
    +"""
    +
    +from __future__ import annotations
    +
    +from types import SimpleNamespace
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +
    +# ── Helpers (mirrors test_streaming.py) ────────────────────────────────────
    +
    +def _make_stream_chunk(content=None, tool_calls=None, finish_reason=None):
    +    delta = SimpleNamespace(
    +        content=content, tool_calls=tool_calls,
    +        reasoning_content=None, reasoning=None,
    +    )
    +    choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
    +    return SimpleNamespace(choices=[choice], model=None, usage=None)
    +
    +
    +def _make_tool_call_delta(index=0, tc_id=None, name=None, arguments=None):
    +    func = SimpleNamespace(name=name, arguments=arguments)
    +    return SimpleNamespace(index=index, id=tc_id, function=func)
    +
    +
    +def _make_agent():
    +    from run_agent import AIAgent
    +    agent = AIAgent(
    +        api_key="[redacted:secret-assignment]",
    +        base_url="https://example.com/v1",
    +        model="test/model",
    +        quiet_mode=True,
    +        skip_context_files=True,
    +        skip_memory=True,
    +    )
    +    agent.api_mode = "chat_completions"
    +    agent._interrupt_requested = False
    +    return agent
    +
    +
    +# ── Stub finish_reason ────────────────────────────────────────────────────
    +
    +class TestPartialStreamStubFinishReason:
    +    """The stub returned by interruptible_streaming_api_call when the
    +    upstream connection dies mid-flight."""
    +
    +    @patch("run_agent.AIAgent._create_request_openai_client")
    +    @patch("run_agent.AIAgent._close_request_openai_client")
    +    def test_text_only_partial_returns_length(self, _mock_close, mock_create, monkeypatch):
    +        """#30963: text-only partials must classify as length so the loop
    +        keeps continuing instead of exiting with budget remaining."""
    +
    +        def _stalling_stream():
    +            yield _make_stream_chunk(content="Here's my answer so far")
    +            raise RuntimeError("simulated upstream stall")
    +
    +        mock_client = MagicMock()
    +        mock_client.chat.completions.create.side_effect = lambda *a, **kw: _stalling_stream()
    +        mock_create.return_value = mock_client
    +
    +        agent = _make_agent()
    +        agent._current_streamed_assistant_text = "Here's my answer so far"
    +
    +        monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
    +        response = agent._interruptible_streaming_api_call({})
    +
    +        assert response.id == "partial-stream-stub"
    +        assert response.choices[0].finish_reason == "length", (
    +            "Text-only partial streams must use finish_reason=length so the "
    +            "conversation loop continues from where the network died "
    +            "(issue #30963)."
    +        )
    +        assert response.choices[0].message.content == "Here's my answer so far"
    +        assert response.choices[0].message.tool_calls is None
    +
    +    @patch("run_agent.AIAgent._create_request_openai_client")
    +    @patch("run_agent.AIAgent._close_request_openai_client")
    +    def test_partial_tool_call_keeps_stop(self, _mock_close, mock_create, monkeypatch):
    +        """Mid-tool-call partials keep finish_reason=stop on purpose — the
    +        warning text asks the user to drive the retry, not the agent."""
    +
    +        def _stalling_stream():
    +            yield _make_stream_chunk(content="Let me write the audit: ")
    +            yield _make_stream_chunk(tool_calls=[
    +                _make_tool_call_delta(index=0, tc_id="call_1", name="write_file"),
    +            ])
    +            yield _make_stream_chunk(tool_calls=[
    +                _make_tool_call_delta(index=0, arguments='{"path": "/tmp/x", '),
    +            ])
    +            raise RuntimeError("simulated upstream stall")
    +
    +        mock_client = MagicMock()
    +        mock_client.chat.completions.create.side_effect = lambda *a, **kw: _stalling_stream()
    +        mock_create.return_value = mock_client
    +
    +        agent = _make_agent()
    +        agent._fire_stream_delta = lambda text: None
    +        agent._current_streamed_assistant_text = "Let me write the audit: "
    +
    +        monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
    +        response = agent._interruptible_streaming_api_call({})
    +
    +        assert response.id == "partial-stream-stub"
    +        assert response.choices[0].finish_reason == "stop", (
    +            "Partial mid-tool-call must keep finish_reason=stop — the warning "
    +            "appended to content asks the user to retry, so the agent must "
    +            "not auto-replay a tool call with possible side-effects."
    +        )
    +        content = response.choices[0].message.content or ""
    +        assert "Stream stalled mid tool-call" in content
    +        assert "write_file" in content
    +
    +
    +# ── Length-continuation prompt branching ──────────────────────────────────
    +
    +class TestLengthContinuationPromptBranching:
    +    """When finish_reason=length, the continuation prompt that reaches the
    +    model has to tell the truth: real truncation vs. network interruption.
    +    Lying ("you were truncated") on a partial-stream stub leads the model
    +    to no-op ("I wasn't truncated, I'm done"), defeating recovery."""
    +
    +    def _simulate_branch(self, response_id: str) -> str:
    +        """Return the continuation prompt text the loop would inject for
    +        a `finish_reason=length` response with the given id. Mirrors the
    +        exact branch in agent/conversation_loop.py."""
    +        response = SimpleNamespace(id=response_id)
    +        if getattr(response, "id", "") == "partial-stream-stub":
    +            return (
    +                "[System: The previous response was cut off by a "
    +                "network error mid-stream. Continue exactly where "
    +                "you left off. Do not restart or repeat prior text. "
    +                "Finish the answer directly.]"
    +            )
    +        return (
    +            "[System: Your previous response was truncated by the output "
    +            "length limit. Continue exactly where you left off. Do not "
    +            "restart or repeat prior text. Finish the answer directly.]"
    +        )
    +
    +    def test_partial_stream_stub_uses_network_prompt(self):
    +        prompt = self._simulate_branch("partial-stream-stub")
    +        assert "network error mid-stream" in prompt
    +        assert "output length limit" not in prompt
    +
    +    def test_real_truncation_uses_length_prompt(self):
    +        prompt = self._simulate_branch("chatcmpl-abc123")
    +        assert "output length limit" in prompt
    +        assert "network error" not in prompt
    +
    +    def test_no_id_falls_through_to_length_prompt(self):
    +        prompt = self._simulate_branch("")
    +        assert "output length limit" in prompt
    +
    +
    +# ── Integration: live conversation loop ───────────────────────────────────
    +
    [redacted:email]()
    +def loop_agent():
    +    """AIAgent with a mocked OpenAI client (mirrors test_run_agent's fixture)
    +    so we can stage a stub + continuation pair on .chat.completions.create."""
    +    from run_agent import AIAgent
    +    with (
    +        patch("run_agent.get_tool_definitions", return_value=[]),
    +        patch("run_agent.check_toolset_requirements", return_value={}),
    +        patch("run_agent.OpenAI"),
    +    ):
    +        a = AIAgent(
    +            api_key="[redacted:secret-assignment]",
    +            base_url="https://openrouter.ai/api/v1",
    +            quiet_mode=True,
    +            skip_context_files=True,
    +            skip_memory=True,
    +        )
    +        a.client = MagicMock()
    +        a._cached_system_prompt = "You are helpful."
    +        a._use_prompt_caching = False
    +        a.tool_delay = 0
    +        a.compression_enabled = False
    +        a.save_trajectories = False
    +        return a
    +
    +
    +class TestConversationLoopPartialStreamContinuation:
    +    """End-to-end: a partial-stream stub feeds the loop and the loop
    +    asks for continuation instead of exiting with finish_reason=stop."""
    +
    +    def test_partial_stream_stub_does_not_exit_loop_immediately(self, loop_agent):
    +        """The stub from chat_completion_helpers used to exit the loop with
    +        text_response(finish_reason=stop). Now finish_reason=length routes
    +        through length_continue_retries — the loop persists the partial
    +        content and asks the model to continue."""
    +
    +        from tests.run_agent.test_run_agent import _mock_response, _mock_assistant_msg
    +
    +        # First API call: the partial-stream stub (length on partial-stream-stub id).
    +        partial_stub = SimpleNamespace(
    +            id="partial-stream-stub",
    +            model="test/model",
    +            choices=[SimpleNamespace(
    +                index=0,
    +                message=_mock_assistant_msg(content="The first half of "),
    +                finish_reason="length",
    +            )],
    +            usage=None,
    +        )
    +        # Second API call: model continues with the rest, clean stop.
    +        continuation = _mock_response(
    +            content="the answer is forty-two.", finish_reason="stop",
    +        )
    +
    +        loop_agent.client.chat.completions.create.side_effect = [
    +            partial_stub, continuation,
    +        ]
    +
    +        with (
    +            patch.object(loop_agent, "_persist_session"),
    +            patch.object(loop_agent, "_save_trajectory"),
    +            patch.object(loop_agent, "_cleanup_task_resources"),
    +        ):
    +            result = loop_agent.run_conversation("ask me something")
    +
    +        # The loop made TWO API calls (stub + continuation), not one.
    +        assert loop_agent.client.chat.completions.create.call_count == 2, (
    +            "Partial-stream-stub must trigger a continuation API call, not "
    +            "exit the loop after one call."
    +        )
    +        # The continuation prompt the loop appended must be the network-error
    +        # variant, not the "output length limit" lie — otherwise the model
    +        # no-ops with "I wasn't truncated, I'm done."
    +        # We assert it indirectly by inspecting the second-call kwargs.
    +        second_call_kwargs = loop_agent.client.chat.completions.create.call_args_list[1]
    +        msgs = second_call_kwargs.kwargs.get("messages") or second_call_kwargs.args[0].get("messages")
    +        last_user = next(
    +            (m for m in reversed(msgs) if m.get("role") == "user"), None,
    +        )
    +        assert last_user is not None
    +        assert "network error mid-stream" in (last_user.get("content") or ""), (
    +            "Continuation prompt for partial-stream-stub must mention the "
    +            "network error, not the 'output length limit'."
    +        )
    +
    +        # And the final response stitches both halves together.
    +        assert "first half of" in result["final_response"]
    +        assert "forty-two" in result["final_response"]
    ```
- **#32012: fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/chat_completion_helpers.py`, `agent/conversation_loop.py`, `hermes_constants.py`, `tests/run_agent/test_partial_stream_finish_reason.py`
  - fix hunks:
  - `agent/chat_completion_helpers.py`

    ```diff
    @@ -34,6 +34,7 @@
     from urllib.parse import urlparse, parse_qs, urlunparse
     
     from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
    +from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
     from agent.error_classifier import classify_api_error, FailoverReason
     from agent.model_metadata import is_local_endpoint
     from agent.message_sanitization import (
    ```

    ```diff
    @@ -2151,37 +2152,15 @@ def _call():
             if deltas_were_sent["yes"]:
                 # Streaming failed AFTER some tokens were already delivered to
                 # the platform.  Re-raising would let the outer retry loop make
    -            # a new API call, creating a duplicate message.  Return a
    -            # partial response stub instead and let the outer loop decide:
    -            #
    -            #   - text-only partials → finish_reason="length" so the
    -            #     conversation loop persists the partial assistant content
    -            #     and asks the model to continue from where the stream
    -            #     died (issue #30963: partial stop misclassified as a
    -            #     clean completion was exiting the loop with budget
    -            #     remaining and an unfinished goal).
    -            #
    -            #   - partial mid-tool-call → finish_reason="stop" stays.
    -            #     The user-visible warning we append says "Ask me to
    -            #     retry if you want to continue", so the agent should
    -            #     hand control back rather than auto-retry a tool call
    -            #     that may have side-effects.
    -            #
    -            # Recover whatever content was already streamed to the user.
    -            # _current_streamed_assistant_text accumulates text fired
    -            # through _fire_stream_delta, so it has exactly what the
    -            # user saw before the connection died.
    +            # Return a partial response stub with finish_reason="length"
    +            # so the conversation loop's continuation machinery fires.
    +            # tool_calls=None prevents auto-execution of incomplete calls.
                 _partial_text = (
                     getattr(agent, "_current_streamed_assistant_text", "") or ""
                 ).strip() or None
     
    -            # If the stream died while the model was emitting a tool call,
    -            # the stub below will silently set `tool_calls=None` and the
    -            # agent loop will treat the turn as complete — the attempted
    -            # action is lost with no user-facing signal.  Append a
    -            # human-visible warning to the stub content so (a) the user
    -            # knows something failed, and (b) the next turn's model sees
    -            # in conversation history what was attempted and can retry.
    +            # Append a user-visible warning if tool calls were dropped so
    +            # the user and model both know what was attempted.
                 _partial_names = list(result.get("partial_tool_names") or [])
                 if _partial_names:
                     _name_str = ", ".join(_partial_names[:3])
    ```

    ```diff
    @@ -2193,8 +2172,7 @@ def _call():
                         f"Ask me to retry if you want to continue."
                     )
                     _partial_text = (_partial_text or "") + _warn
    -                # Also fire as a streaming delta so the user sees it now
    -                # instead of only in the persisted transcript.
    +                # Fire as streaming delta so the user sees it immediately.
                     try:
                         agent._fire_stream_delta(_warn)
                     except Exception:
    ```

    ```diff
    @@ -2204,7 +2182,7 @@ def _call():
                         "of text; surfaced warning to user: %s",
                         _partial_names, len(_partial_text or ""), result["error"],
                     )
    -                _stub_finish_reason = "stop"
    +                _stub_finish_reason = FINISH_REASON_LENGTH
                 else:
                     logger.warning(
                         "Partial stream delivered before error; returning "
    ```

    ```diff
    @@ -2214,18 +2192,19 @@ def _call():
                         len(_partial_text or ""),
                         result["error"],
                     )
    -                _stub_finish_reason = "length"
    +                _stub_finish_reason = FINISH_REASON_LENGTH
                 _stub_msg = SimpleNamespace(
                     role="assistant", content=_partial_text, tool_calls=None,
                     reasoning_content=None,
                 )
                 return SimpleNamespace(
    -                id="partial-stream-stub",
    +                id=PARTIAL_STREAM_STUB_ID,
                     model=getattr(agent, "model", "unknown"),
                     choices=[SimpleNamespace(
                         index=0, message=_stub_msg, finish_reason=_stub_finish_reason,
                     )],
                     usage=None,
    +                _dropped_tool_names=_partial_names or None,
                 )
             raise result["error"]
         return result["response"]
    ```
  - `agent/conversation_loop.py`

    ```diff
    @@ -65,7 +65,7 @@
     from agent.retry_utils import jittered_backoff
     from agent.trajectory import has_incomplete_scratchpad
     from agent.usage_pricing import estimate_usage_cost, normalize_usage
    -from hermes_constants import display_hermes_home as _dhh_fn
    +from hermes_constants import display_hermes_home as _dhh_fn, PARTIAL_STREAM_STUB_ID
     from hermes_logging import set_session_context
     from tools.schema_sanitizer import strip_pattern_and_format
     from tools.skill_provenance import set_current_write_origin
    ```

    ```diff
    @@ -229,6 +229,37 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
                 )
     
     
    +def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
    +    if is_partial_stub and dropped_tools:
    +        tool_list = ", ".join(dropped_tools[:3])
    +        return (
    +            "[System: Your previous tool call "
    +            f"({tool_list}) was too large and "
    +            "the stream timed out before it "
    +            "could be delivered. Do NOT retry "
    +            "the same tool call with the same "
    +            "large content. Instead, break the "
    +            "content into multiple smaller tool "
    +            "calls (e.g. use multiple patch calls "
    +            "or write smaller files). Each tool "
    +            "call's arguments must be under ~8K "
    +            "tokens to avoid stream timeouts.]"
    +        )
    +    elif is_partial_stub:
    +        return (
    +            "[System: The previous response was cut off by a "
    +            "network error mid-stream. Continue exactly where "
    +            "you left off. Do not restart or repeat prior text. "
    +            "Finish the answer directly.]"
    +        )
    +    else:
    +        return (
    +            "[System: Your previous response was truncated by the output "
    +            "length limit. Continue exactly where you left off. Do not "
    +            "restart or repeat prior text. Finish the answer directly.]"
    +        )
    +
    +
     def run_conversation(
         agent,
         user_message: str,
    ```

    ```diff
    @@ -1414,7 +1445,7 @@ def _stop_spinner():
                             finish_reason = "length"
     
                     if finish_reason == "length":
    -                    if getattr(response, "id", "") == "partial-stream-stub":
    +                    if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
                             agent._vprint(
                                 f"{agent.log_prefix}⚠️  Stream interrupted by network error "
                                 f"(finish_reason='length' on partial-stream-stub)",
    ```

    ```diff
    @@ -1518,37 +1549,36 @@ def _stop_spinner():
                                     truncated_response_parts.append(assistant_message.content)
     
                                 if length_continue_retries < 3:
    -                                # Distinguish a real output-token truncation
    -                                # from a partial-stream-stub network error
    -                                # (#30963).  Same continuation machinery,
    -                                # but the prompt has to tell the truth or
    -                                # the model goes off rails ("I wasn't
    -                                # truncated, I'm done").
                                     _is_partial_stream_stub = (
    -                                    getattr(response, "id", "") == "partial-stream-stub"
    +                                    getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
                                     )
    -                                if _is_partial_stream_stub:
    +                                _dropped_tools = getattr(
    +                                    response, "_dropped_tool_names", None
    +                                )
    +
    +                                if _is_partial_stream_stub and _dropped_tools:
    +                                    _tool_list = ", ".join(_dropped_tools[:3])
    +                                    agent._vprint(
    +                                        f"{agent.log_prefix}↻ Stream interrupted mid "
    +                                        f"tool-call ({_tool_list}) — requesting "
    +                                        f"chunked retry "
    +                                        f"({length_continue_retries}/3)..."
    +                                    )
    +                                elif _is_partial_stream_stub:
                                         agent._vprint(
                                             f"{agent.log_prefix}↻ Stream interrupted — "
                                             f"requesting continuation "
                                             f"({length_continue_retries}/3)..."
                                         )
    -                                    _continue_content = (
    -                                        "[System: The previous response was cut off by a "
    -                                        "network error mid-stream. Continue exactly where "
    -                                        "you left off. Do not restart or repeat prior text. "
    -                                        "Finish the answer directly.]"
    -                                    )
                                     else:
                                         agent._vprint(
                                             f"{agent.log_prefix}↻ Requesting continuation "
                                             f"({length_continue_retries}/3)..."
                                         )
    -                                    _continue_content = (
    -                                        "[System: Your previous response was truncated by the output "
    -                                        "length limit. Continue exactly where you left off. Do not "
    -                                        "restart or repeat prior text. Finish the answer directly.]"
    -                                    )
    +
    +                                _continue_content = _get_continuation_prompt(
    +                                    _is_partial_stream_stub, _dropped_tools
    +                                )
                                     continue_msg = {
                                         "role": "user",
                                         "content": _continue_content,
    ```
  - `hermes_constants.py`

    ```diff
    @@ -432,6 +432,14 @@ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
         socket.getaddrinfo = _ipv4_getaddrinfo  # type: ignore[assignment]
     
     
    +# ─── Streaming Response Constants ────────────────────────────────────────────
    +
    +# Response ID for partial stream stubs used during error recovery
    +PARTIAL_STREAM_STUB_ID = "partial-stream-stub"
    +
    +FINISH_REASON_LENGTH = "length"
    +
    +
     OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
     OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
     
    ```
  - `tests/run_agent/test_partial_stream_finish_reason.py`

    ```diff
    @@ -5,9 +5,9 @@
     - text-only partial stream → stub.finish_reason == "length" so the
       conversation loop's existing length-continuation path can keep the
       agent moving against an unfinished goal.
    -- partial mid-tool-call → stub.finish_reason == "stop" so the loop
    -  hands control back to the user (matches the user-visible warning
    -  "Ask me to retry if you want to continue").
    +- partial mid-tool-call → stub.finish_reason == "length" so the loop
    +  triggers continuation machinery with targeted chunking guidance
    +  instead of ending the turn immediately.
     - conversation_loop's length-continuation prompt distinguishes a real
       output-length truncation from a partial-stream-stub network error
       via response.id.
    ```

    ```diff
    @@ -20,6 +20,9 @@
     
     import pytest
     
    +from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
    +from agent.conversation_loop import _get_continuation_prompt
    +
     
     # ── Helpers (mirrors test_streaming.py) ────────────────────────────────────
     
    ```

    ```diff
    @@ -78,8 +81,8 @@ def _stalling_stream():
             monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
             response = agent._interruptible_streaming_api_call({})
     
    -        assert response.id == "partial-stream-stub"
    -        assert response.choices[0].finish_reason == "length", (
    +        assert response.id == PARTIAL_STREAM_STUB_ID
    +        assert response.choices[0].finish_reason == FINISH_REASON_LENGTH, (
                 "Text-only partial streams must use finish_reason=length so the "
                 "conversation loop continues from where the network died "
                 "(issue #30963)."
    ```

    ```diff
    @@ -89,9 +92,11 @@ def _stalling_stream():
     
         @patch("run_agent.AIAgent._create_request_openai_client")
         @patch("run_agent.AIAgent._close_request_openai_client")
    -    def test_partial_tool_call_keeps_stop(self, _mock_close, mock_create, monkeypatch):
    -        """Mid-tool-call partials keep finish_reason=stop on purpose — the
    -        warning text asks the user to drive the retry, not the agent."""
    +    def test_partial_tool_call_uses_length(self, _mock_close, mock_create, monkeypatch):
    +        """Mid-tool-call partials now use finish_reason=length so the
    +        conversation loop's continuation machinery fires — bounded 3-retry
    +        with guidance to break output into smaller chunks (#31998).
    +        tool_calls=None is preserved, so no tool auto-executes."""
     
             def _stalling_stream():
                 yield _make_stream_chunk(content="Let me write the audit: ")
    ```

    ```diff
    @@ -114,12 +119,18 @@ def _stalling_stream():
             monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
             response = agent._interruptible_streaming_api_call({})
     
    -        assert response.id == "partial-stream-stub"
    -        assert response.choices[0].finish_reason == "stop", (
    -            "Partial mid-tool-call must keep finish_reason=stop — the warning "
    -            "appended to content asks the user to retry, so the agent must "
    -            "not auto-replay a tool call with possible side-effects."
    +        assert response.id == PARTIAL_STREAM_STUB_ID
    +        assert response.choices[0].finish_reason == FINISH_REASON_LENGTH, (
    +            "Partial mid-tool-call must use finish_reason=length so the "
    +            "continuation machinery fires instead of ending the turn "
    +            "immediately (#31998)."
    +        )
    +        assert response.choices[0].message.tool_calls is None, (
    +            "tool_calls must remain None (no auto-execution of side-effectful "
    +            "tool calls)."
             )
    +        # The stub should carry dropped tool names for continuation prompt
    +        assert getattr(response, "_dropped_tool_names", None) == ["write_file"]
             content = response.choices[0].message.content or ""
             assert "Stream stalled mid tool-call" in content
             assert "write_file" in content
    ```

    ```diff
    @@ -129,30 +140,17 @@ def _stalling_stream():
     
     class TestLengthContinuationPromptBranching:
         """When finish_reason=length, the continuation prompt that reaches the
    -    model has to tell the truth: real truncation vs. network interruption.
    -    Lying ("you were truncated") on a partial-stream stub leads the model
    -    to no-op ("I wasn't truncated, I'm done"), defeating recovery."""
    +    model has to tell the truth: real truncation vs. network interruption
    +    vs. dropped tool call (#31998).  Three distinct prompts now exist."""
     
    -    def _simulate_branch(self, response_id: str) -> str:
    +    def _simulate_branch(self, response_id: str, dropped_tools=None) -> str:
             """Return the continuation prompt text the loop would inject for
    -        a `finish_reason=length` response with the given id. Mirrors the
    -        exact branch in agent/conversation_loop.py."""
    -        response = SimpleNamespace(id=response_id)
    -        if getattr(response, "id", "") == "partial-stream-stub":
    -            return (
    -                "[System: The previous response was cut off by a "
    -                "network error mid-stream. Continue exactly where "
    -                "you left off. Do not restart or repeat prior text. "
    -                "Finish the answer directly.]"
    -            )
    -        return (
    -            "[System: Your previous response was truncated by the output "
    -            "length limit. Continue exactly where you left off. Do not "
    -            "restart or repeat prior text. Finish the answer directly.]"
    -        )
    +        a `finish_reason=length` response with the given id."""
    +        is_partial = response_id == PARTIAL_STREAM_STUB_ID
    +        return _get_continuation_prompt(is_partial, dropped_tools)
     
         def test_partial_stream_stub_uses_network_prompt(self):
    -        prompt = self._simulate_branch("partial-stream-stub")
    +        prompt = self._simulate_branch(PARTIAL_STREAM_STUB_ID)
             assert "network error mid-stream" in prompt
             assert "output length limit" not in prompt
     
    ```

    ```diff
    @@ -165,6 +163,19 @@ def test_no_id_falls_through_to_length_prompt(self):
             prompt = self._simulate_branch("")
             assert "output length limit" in prompt
     
    +    def test_dropped_tool_call_uses_chunking_prompt(self):
    +        """When the stub dropped a tool call, the continuation prompt
    +        must guide the model to break its output into smaller chunks
    +        instead of retrying the same large tool call (#31998)."""
    +        prompt = self._simulate_branch(
    +            PARTIAL_STREAM_STUB_ID, dropped_tools=["write_file"],
    +        )
    +        assert "too large" in prompt
    +        assert "break" in prompt.lower()
    +        assert "write_file" in prompt
    +        assert "network error" not in prompt
    +        assert "output length limit" not in prompt
    +
     
     # ── Integration: live conversation loop ───────────────────────────────────
     
    ```

    ```diff
    @@ -208,12 +219,12 @@ def test_partial_stream_stub_does_not_exit_loop_immediately(self, loop_agent):
     
             # First API call: the partial-stream stub (length on partial-stream-stub id).
             partial_stub = SimpleNamespace(
    -            id="partial-stream-stub",
    +            id=PARTIAL_STREAM_STUB_ID,
                 model="test/model",
                 choices=[SimpleNamespace(
                     index=0,
                     message=_mock_assistant_msg(content="The first half of "),
    -                finish_reason="length",
    +                finish_reason=FINISH_REASON_LENGTH,
                 )],
                 usage=None,
             )
    ```
- **#30988: fix(streaming): use finish_reason=length for partial stream stub**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#30998`

## Fix status

- Merged to main in PR #30998 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-25`.
