Agent silently drops responses after tool calls

NousResearch/hermes-agent · cluster 6522 · agent

Data as of

Nothing open on GitHub. Fix merged in #10472. Check it covers your case.

The short answer

0open issues
0open PRs
15items grouped
Known?
Yes. 15 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #10472. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #23420. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #10472 fix: prevent premature loop exit when weak models return empty after substantive tool calls

    merged to main

    files: run_agent.py

    Show the diff
    • run_agent.py
      @@ -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
       
      @@ -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]
      @@ -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:
      @@ -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
      @@ -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
      @@ -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"
  • #23420 fix(gateway): only mark final response sent when split-overflow chunks actually land

    merged to main

    files: gateway/stream_consumer.py, tests/gateway/test_stream_consumer.py

    Show the diff
    • gateway/stream_consumer.py
      @@ -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
      @@ -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):

7 PRs closed without merging

From the close comments: done elsewhere 5 · closed without comment 1 · other 1

  • #8024 fix: move _last_content_with_tools fallback after retry mechanisms · done elsewhere → 0d25e1c, #7968, #10472
  • #9432 Bugfix: avoid stale tool-turn fallback on empty responses · done elsewhere
  • #10750 fix(gateway): prevent streaming mode from silently dropping final response · closed without comment
  • #10777 fix: batch fix for 4 issues (#10721, #10755, #10723, #10748) · done elsewhere → c038587, 45e1228, 50d438d, d67e602, d4eba82, 3b5572d, #10721, #10755, #10723, #10843, #10748
  • #10797 fix(gateway): prevent streaming mode from silently dropping final response · other → #11018
Show 2 more
  • #11002 fix(gateway): prevent interim_assistant_messages from suppressing final response · done elsewhere
  • #11138 fix 10942: dont suppress final response after interim assistant message · done elsewhere
Closed issues · 6
  • Closed#6522[Bug]: Agent silently stops responding after tool calls — responses never delivered
  • Closed#7180[Bug]: Hermes Agent gets stuck indefinitely in the middle of execution
  • Closed#7968[Bug]: _last_content_with_tools fallback bypasses empty-response retries, causing silent agent loop termination mid-task
  • Closed#9400Bug: Empty response after tool calls silently abandons incomplete multi-step tasks
  • Closed#10748fix(gateway): streaming mode silently drops final response when already_sent is true
  • Closed#10942Bug: interim_assistant_messages causes final response to be silently dropped after tool calls
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#10472, #23420
Closed issues
6
Attempts
7 PRs closed without merging
Fix link checked
10 May 2026

For your agent

The same facts, typed.