# NousResearch/hermes-agent · Agent silently drops responses after tool calls

## Open now

- Nothing open on GitHub.

A cluster of bugs and fixes centered on agents silently failing to deliver final responses after tool calls, particularly involving empty responses, streaming mode, interim assistant messages, and fallback logic. Multiple related failure modes are represented across the gateway, agent loop, and streaming layers.

## TL;DR

- 15 member(s): 0 open issue(s), 6 closed issue(s), 0 open PR(s), 2 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #10472. Verify it addresses your case.
- **Recommended:** read PR #10472 (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: `partially_resolved` · deciding PR(s): `pr-23420`. 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.10`: 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

- #6522: \[Bug\]: Agent silently stops responding after tool calls — responses never delivered
- #7180: \[Bug\]: Hermes Agent gets stuck indefinitely in the middle of execution
- #7968: \[Bug\]: _last_content_with_tools fallback bypasses empty-response retries, causing silent agent loop termination mid-task
- #9400: Bug: Empty response after tool calls silently abandons incomplete multi-step tasks
- #10748: fix(gateway): streaming mode silently drops final response when already_sent is true
- #10942: Bug: interim_assistant_messages causes final response to be silently dropped after tool calls

## Prior & competing PRs

- **#10472: fix: prevent premature loop exit when weak models return empty after substantive tool calls**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -7881,6 +7881,7 @@ def run_conversation(
             self._thinking_prefill_retries = 0
             self._post_tool_empty_retried = False
             self._last_content_with_tools = None
    +        self._last_content_tools_all_housekeeping = False
             self._mute_post_response = False
             self._unicode_sanitization_passes = 0
     
    ```

    ```diff
    @@ -8068,6 +8069,7 @@ def run_conversation(
                         self._empty_content_retries = 0
                         self._thinking_prefill_retries = 0
                         self._last_content_with_tools = None
    +                    self._last_content_tools_all_housekeeping = False
                         self._mute_post_response = False
                         # Re-estimate after compression
                         _preflight_tokens = [redacted:secret-assignment]
    ```

    ```diff
    @@ -10130,6 +10132,7 @@ def _stop_spinner():
                                 tc.function.name in _HOUSEKEEPING_TOOLS
                                 for tc in assistant_message.tool_calls
                             )
    +                        self._last_content_tools_all_housekeeping = _all_housekeeping
                             if _all_housekeeping and self._has_stream_consumers():
                                 self._mute_post_response = True
                             elif self.quiet_mode:
    ```

    ```diff
    @@ -10312,15 +10315,22 @@ def _stop_spinner():
                                 break
     
                             # If the previous turn already delivered real content alongside
    -                        # tool calls (e.g. "You're welcome!" + memory save), the model
    -                        # has nothing more to say. Use the earlier content immediately
    -                        # instead of wasting API calls on retries that won't help.
    +                        # HOUSEKEEPING tool calls (e.g. "You're welcome!" + memory save),
    +                        # the model has nothing more to say. Use the earlier content
    +                        # immediately instead of wasting API calls on retries.
    +                        # NOTE: Only use this shortcut when ALL tools in that turn were
    +                        # housekeeping (memory, todo, etc.).  When substantive tools
    +                        # were called (terminal, search_files, etc.), the content was
    +                        # likely mid-task narration ("I'll scan the directory...") and
    +                        # the empty follow-up means the model choked — let the
    +                        # post-tool nudge below handle that instead of exiting early.
                             fallback = getattr(self, '_last_content_with_tools', None)
    -                        if fallback:
    +                        if fallback and getattr(self, '_last_content_tools_all_housekeeping', False):
                                 _turn_exit_reason = "fallback_prior_turn_content"
                                 logger.info("Empty follow-up after tool calls — using prior turn content as final response")
                                 self._emit_status("↻ Empty response after tool calls — using earlier content as final answer")
                                 self._last_content_with_tools = None
    +                            self._last_content_tools_all_housekeeping = False
                                 self._empty_content_retries = 0
                                 # Do NOT modify the assistant message content — the
                                 # old code injected "Calling the X tools..." which
    ```

    ```diff
    @@ -10331,13 +10341,18 @@ def _stop_spinner():
                                 break
     
                             # ── Post-tool-call empty response nudge ───────────
    -                        # The model returned empty after executing tool calls
    -                        # but there's no prior-turn content to fall back on.
    +                        # The model returned empty after executing tool calls.
    +                        # This covers two cases:
    +                        #  (a) No prior-turn content at all — model went silent
    +                        #  (b) Prior turn had content + SUBSTANTIVE tools (the
    +                        #      fallback above was skipped because the content
    +                        #      was mid-task narration, not a final answer)
                             # Instead of giving up, nudge the model to continue by
                             # appending a user-level hint.  This is the #9400 case:
    -                        # weaker models (GLM-5, etc.) sometimes return empty
    -                        # after tool results instead of continuing to the next
    -                        # step.  One retry with a nudge usually fixes it.
    +                        # weaker models (mimo-v2-pro, GLM-5, etc.) sometimes
    +                        # return empty after tool results instead of continuing
    +                        # to the next step.  One retry with a nudge usually
    +                        # fixes it.
                             _prior_was_tool = any(
                                 m.get("role") == "tool"
                                 for m in messages[-5:]  # check recent messages
    ```

    ```diff
    @@ -10347,6 +10362,10 @@ def _stop_spinner():
                                 and not getattr(self, "_post_tool_empty_retried", False)
                             ):
                                 self._post_tool_empty_retried = True
    +                            # Clear stale narration so it doesn't resurface
    +                            # on a later empty response after the nudge.
    +                            self._last_content_with_tools = None
    +                            self._last_content_tools_all_housekeeping = False
                                 logger.info(
                                     "Empty response after tool calls — nudging model "
                                     "to continue processing"
    ```
- **#10777: fix: batch fix for 4 issues (#10721, #10755, #10723, #10748)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c03858733`, `45e1228a8`, `50d438d12`, `d67e602cc`, `d4eba82a3`, `3b5572ded`, `#10721`, `#10755`, `#10723`, `#10843`, `#10748`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#10797: fix(gateway): prevent streaming mode from silently dropping final response**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#11018`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11002: fix(gateway): prevent interim_assistant_messages from suppressing final response**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11138: fix 10942: dont suppress final response after interim assistant message**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23420: fix(gateway): only mark final response sent when split-overflow chunks actually land**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/stream_consumer.py`, `tests/gateway/test_stream_consumer.py`
  - fix hunks:
  - `gateway/stream_consumer.py`

    ```diff
    @@ -362,13 +362,21 @@ async def run(self) -> None:
                             chunks = self.adapter.truncate_message(
                                 self._accumulated, _safe_limit
                             )
    +                        chunks_delivered = False
    +                        reply_to = self._message_id
                             for chunk in chunks:
    -                            await self._send_new_chunk(chunk, self._message_id)
    +                            new_id = await self._send_new_chunk(chunk, reply_to)
    +                            if new_id is not None and new_id != reply_to:
    +                                chunks_delivered = True
                             self._accumulated = ""
                             self._last_sent_text = ""
                             self._last_edit_time = time.monotonic()
                             if got_done:
    -                            self._final_response_sent = self._already_sent
    +                            # Only claim final delivery if THESE chunks actually
    +                            # landed.  ``_already_sent`` may be True from prior
    +                            # tool-progress edits or fallback-mode promotion (#10748)
    +                            # — that doesn't mean the final answer reached the user.
    +                            self._final_response_sent = chunks_delivered
                                 return
                             if got_segment_break:
                                 self._message_id = None
    ```
  - `tests/gateway/test_stream_consumer.py`

    ```diff
    @@ -867,6 +867,78 @@ async def test_fallback_final_skips_delete_when_adapter_lacks_method(self):
             assert consumer._final_response_sent is True
     
     
    +class TestFinalResponseDeliveryGuard:
    +    """Regression coverage for #10748 — _final_response_sent must reflect
    +    actual delivery of the *current* chunked send, not the cumulative
    +    `_already_sent` flag (which earlier tool-progress edits or fallback-mode
    +    promotion can taint)."""
    +
    +    @pytest.mark.asyncio
    +    async def test_split_overflow_failed_send_does_not_mark_final_sent(self):
    +        """Split-overflow path: if every chunk send fails on done frame,
    +        _final_response_sent must stay False so the gateway falls back."""
    +        adapter = MagicMock()
    +        # Every send fails — _send_new_chunk returns the passed-in reply_to.
    +        adapter.send = AsyncMock(
    +            return_value=SimpleNamespace(success=False, error="network down"),
    +        )
    +        adapter.edit_message = AsyncMock(
    +            return_value=SimpleNamespace(success=True),
    +        )
    +        adapter.MAX_MESSAGE_LENGTH = 100
    +        adapter.truncate_message = MagicMock(
    +            side_effect=lambda text, limit: [text[:limit], text[limit:]],
    +        )
    +
    +        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
    +        consumer = GatewayStreamConsumer(adapter, "chat_123", config)
    +
    +        # Simulate prior tool-progress edits that set _already_sent
    +        consumer._already_sent = True
    +
    +        # Long text > MAX_MESSAGE_LENGTH, no existing message id (fresh send path)
    +        long_text = "x" * 200
    +        consumer.on_delta(long_text)
    +        task = asyncio.create_task(consumer.run())
    +        await asyncio.sleep(0.05)
    +        consumer.finish()
    +        await task
    +
    +        assert consumer._final_response_sent is False, (
    +            "_already_sent leaked into _final_response_sent — gateway will "
    +            "wrongly suppress its fallback delivery (#10748)"
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_split_overflow_partial_send_marks_final_sent(self):
    +        """Split-overflow path: if at least one chunk lands on done frame,
    +        we did deliver the final answer — _final_response_sent must be True."""
    +        adapter = MagicMock()
    +        adapter.send = AsyncMock(side_effect=[
    +            SimpleNamespace(success=True, message_id="msg_1"),
    +            SimpleNamespace(success=True, message_id="msg_2"),
    +        ])
    +        adapter.edit_message = AsyncMock(
    +            return_value=SimpleNamespace(success=True),
    +        )
    +        adapter.MAX_MESSAGE_LENGTH = 100
    +        adapter.truncate_message = MagicMock(
    +            side_effect=lambda text, limit: [text[:limit], text[limit:]],
    +        )
    +
    +        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
    +        consumer = GatewayStreamConsumer(adapter, "chat_123", config)
    +
    +        long_text = "x" * 200
    +        consumer.on_delta(long_text)
    +        task = asyncio.create_task(consumer.run())
    +        await asyncio.sleep(0.05)
    +        consumer.finish()
    +        await task
    +
    +        assert consumer._final_response_sent is True
    +
    +
     class TestInterimCommentaryMessages:
         @pytest.mark.asyncio
         async def test_commentary_message_stays_separate_from_final_stream(self):
    ```
- **#8024: fix: move _last_content_with_tools fallback after retry mechanisms**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `0d25e1c14`, `0d25e1c146092b36f26205f65e93c25e5147a646`, `0d25e1c146092b36f26205f65e93c25e5147a646`, `#7968`, `#10472`
- **#9432: Bugfix: avoid stale tool-turn fallback on empty responses**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#10750: fix(gateway): prevent streaming mode from silently dropping final response**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #10472 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-10`.
- Subsystem: `agent`
