{"repo":"NousResearch/hermes-agent","public_id":"6522","display_label":"Agent silently drops responses after tool calls","summary":"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.","subsystem":"agent","counts":{"members":15,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":6,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10472},"resolution":{"state":"partially_resolved","by":"pr-23420"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T22:13:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10285714285714287},"fix_edges":[{"issue_number":7968,"pr_number":10472,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":10748,"pr_number":23420,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6522,"title":"[Bug]: Agent silently stops responding after tool calls — responses never delivered","url":null,"state":"closed"},{"kind":"issue","number":7180,"title":"[Bug]: Hermes Agent gets stuck indefinitely in the middle of execution","url":null,"state":"closed"},{"kind":"issue","number":7968,"title":"[Bug]: _last_content_with_tools fallback bypasses empty-response retries, causing silent agent loop termination mid-task","url":null,"state":"closed"},{"kind":"issue","number":9400,"title":"Bug: Empty response after tool calls silently abandons incomplete multi-step tasks","url":null,"state":"closed"},{"kind":"issue","number":10748,"title":"fix(gateway): streaming mode silently drops final response when already_sent is true","url":null,"state":"closed"},{"kind":"issue","number":10942,"title":"Bug: interim_assistant_messages causes final response to be silently dropped after tool calls","url":null,"state":"closed"},{"kind":"pr","number":8024,"title":"fix: move _last_content_with_tools fallback after retry mechanisms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0d25e1c14","number":0},{"kind":"commit","value":"0d25e1c146092b36f26205f65e93c25e5147a646","number":0},{"kind":"commit","value":"0d25e1c146092b36f26205f65e93c25e5147a646","number":0},{"kind":"pr","value":"#7968","number":7968},{"kind":"pr","value":"#10472","number":10472}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":9432,"title":"Bugfix: avoid stale tool-turn fallback on empty responses","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10472,"title":"fix: prevent premature loop exit when weak models return empty after substantive tool calls","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -7881,6 +7881,7 @@ def run_conversation(\n         self._thinking_prefill_retries = 0\n         self._post_tool_empty_retried = False\n         self._last_content_with_tools = None\n+        self._last_content_tools_all_housekeeping = False\n         self._mute_post_response = False\n         self._unicode_sanitization_passes = 0\n ","@@ -8068,6 +8069,7 @@ def run_conversation(\n                     self._empty_content_retries = 0\n                     self._thinking_prefill_retries = 0\n                     self._last_content_with_tools = None\n+                    self._last_content_tools_all_housekeeping = False\n                     self._mute_post_response = False\n                     # Re-estimate after compression\n                     _preflight_tokens = [redacted:secret-assignment]","@@ -10130,6 +10132,7 @@ def _stop_spinner():\n                             tc.function.name in _HOUSEKEEPING_TOOLS\n                             for tc in assistant_message.tool_calls\n                         )\n+                        self._last_content_tools_all_housekeeping = _all_housekeeping\n                         if _all_housekeeping and self._has_stream_consumers():\n                             self._mute_post_response = True\n                         elif self.quiet_mode:","@@ -10312,15 +10315,22 @@ def _stop_spinner():\n                             break\n \n                         # If the previous turn already delivered real content alongside\n-                        # tool calls (e.g. \"You're welcome!\" + memory save), the model\n-                        # has nothing more to say. Use the earlier content immediately\n-                        # instead of wasting API calls on retries that won't help.\n+                        # HOUSEKEEPING tool calls (e.g. \"You're welcome!\" + memory save),\n+                        # the model has nothing more to say. Use the earlier content\n+                        # immediately instead of wasting API calls on retries.\n+                        # NOTE: Only use this shortcut when ALL tools in that turn were\n+                        # housekeeping (memory, todo, etc.).  When substantive tools\n+                        # were called (terminal, search_files, etc.), the content was\n+                        # likely mid-task narration (\"I'll scan the directory...\") and\n+                        # the empty follow-up means the model choked — let the\n+                        # post-tool nudge below handle that instead of exiting early.\n                         fallback = getattr(self, '_last_content_with_tools', None)\n-                        if fallback:\n+                        if fallback and getattr(self, '_last_content_tools_all_housekeeping', False):\n                             _turn_exit_reason = \"fallback_prior_turn_content\"\n                             logger.info(\"Empty follow-up after tool calls — using prior turn content as final response\")\n                             self._emit_status(\"↻ Empty response after tool calls — using earlier content as final answer\")\n                             self._last_content_with_tools = None\n+                            self._last_content_tools_all_housekeeping = False\n                             self._empty_content_retries = 0\n                             # Do NOT modify the assistant message content — the\n                             # old code injected \"Calling the X tools...\" which","@@ -10331,13 +10341,18 @@ def _stop_spinner():\n                             break\n \n                         # ── Post-tool-call empty response nudge ───────────\n-                        # The model returned empty after executing tool calls\n-                        # but there's no prior-turn content to fall back on.\n+                        # The model returned empty after executing tool calls.\n+                        # This covers two cases:\n+                        #  (a) No prior-turn content at all — model went silent\n+                        #  (b) Prior turn had content + SUBSTANTIVE tools (the\n+                        #      fallback above was skipped because the content\n+                        #      was mid-task narration, not a final answer)\n                         # Instead of giving up, nudge the model to continue by\n                         # appending a user-level hint.  This is the #9400 case:\n-                        # weaker models (GLM-5, etc.) sometimes return empty\n-                        # after tool results instead of continuing to the next\n-                        # step.  One retry with a nudge usually fixes it.\n+                        # weaker models (mimo-v2-pro, GLM-5, etc.) sometimes\n+                        # return empty after tool results instead of continuing\n+                        # to the next step.  One retry with a nudge usually\n+                        # fixes it.\n                         _prior_was_tool = any(\n                             m.get(\"role\") == \"tool\"\n                             for m in messages[-5:]  # check recent messages","@@ -10347,6 +10362,10 @@ def _stop_spinner():\n                             and not getattr(self, \"_post_tool_empty_retried\", False)\n                         ):\n                             self._post_tool_empty_retried = True\n+                            # Clear stale narration so it doesn't resurface\n+                            # on a later empty response after the nudge.\n+                            self._last_content_with_tools = None\n+                            self._last_content_tools_all_housekeeping = False\n                             logger.info(\n                                 \"Empty response after tool calls — nudging model \"\n                                 \"to continue processing\"\n"]}]},{"kind":"pr","number":10750,"title":"fix(gateway): prevent streaming mode from silently dropping final response","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":10777,"title":"fix: batch fix for 4 issues (#10721, #10755, #10723, #10748)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c03858733","number":0},{"kind":"commit","value":"45e1228a8","number":0},{"kind":"commit","value":"50d438d12","number":0},{"kind":"commit","value":"d67e602cc","number":0},{"kind":"commit","value":"d4eba82a3","number":0},{"kind":"commit","value":"3b5572ded","number":0},{"kind":"pr","value":"#10721","number":10721},{"kind":"pr","value":"#10755","number":10755},{"kind":"pr","value":"#10723","number":10723},{"kind":"pr","value":"#10843","number":10843},{"kind":"pr","value":"#10748","number":10748}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10797,"title":"fix(gateway): prevent streaming mode from silently dropping final response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11018","number":11018}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11002,"title":"fix(gateway): prevent interim_assistant_messages from suppressing final response","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11138,"title":"fix 10942: dont suppress final response after interim assistant message","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":23420,"title":"fix(gateway): only mark final response sent when split-overflow chunks actually land","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/stream_consumer.py","tests/gateway/test_stream_consumer.py"],"fix_hunks":[{"file":"gateway/stream_consumer.py","hunks":["@@ -362,13 +362,21 @@ async def run(self) -> None:\n                         chunks = self.adapter.truncate_message(\n                             self._accumulated, _safe_limit\n                         )\n+                        chunks_delivered = False\n+                        reply_to = self._message_id\n                         for chunk in chunks:\n-                            await self._send_new_chunk(chunk, self._message_id)\n+                            new_id = await self._send_new_chunk(chunk, reply_to)\n+                            if new_id is not None and new_id != reply_to:\n+                                chunks_delivered = True\n                         self._accumulated = \"\"\n                         self._last_sent_text = \"\"\n                         self._last_edit_time = time.monotonic()\n                         if got_done:\n-                            self._final_response_sent = self._already_sent\n+                            # Only claim final delivery if THESE chunks actually\n+                            # landed.  ``_already_sent`` may be True from prior\n+                            # tool-progress edits or fallback-mode promotion (#10748)\n+                            # — that doesn't mean the final answer reached the user.\n+                            self._final_response_sent = chunks_delivered\n                             return\n                         if got_segment_break:\n                             self._message_id = None"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -867,6 +867,78 @@ async def test_fallback_final_skips_delete_when_adapter_lacks_method(self):\n         assert consumer._final_response_sent is True\n \n \n+class TestFinalResponseDeliveryGuard:\n+    \"\"\"Regression coverage for #10748 — _final_response_sent must reflect\n+    actual delivery of the *current* chunked send, not the cumulative\n+    `_already_sent` flag (which earlier tool-progress edits or fallback-mode\n+    promotion can taint).\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_split_overflow_failed_send_does_not_mark_final_sent(self):\n+        \"\"\"Split-overflow path: if every chunk send fails on done frame,\n+        _final_response_sent must stay False so the gateway falls back.\"\"\"\n+        adapter = MagicMock()\n+        # Every send fails — _send_new_chunk returns the passed-in reply_to.\n+        adapter.send = AsyncMock(\n+            return_value=SimpleNamespace(success=False, error=\"network down\"),\n+        )\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=True),\n+        )\n+        adapter.MAX_MESSAGE_LENGTH = 100\n+        adapter.truncate_message = MagicMock(\n+            side_effect=lambda text, limit: [text[:limit], text[limit:]],\n+        )\n+\n+        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)\n+        consumer = GatewayStreamConsumer(adapter, \"chat_123\", config)\n+\n+        # Simulate prior tool-progress edits that set _already_sent\n+        consumer._already_sent = True\n+\n+        # Long text > MAX_MESSAGE_LENGTH, no existing message id (fresh send path)\n+        long_text = \"x\" * 200\n+        consumer.on_delta(long_text)\n+        task = asyncio.create_task(consumer.run())\n+        await asyncio.sleep(0.05)\n+        consumer.finish()\n+        await task\n+\n+        assert consumer._final_response_sent is False, (\n+            \"_already_sent leaked into _final_response_sent — gateway will \"\n+            \"wrongly suppress its fallback delivery (#10748)\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_split_overflow_partial_send_marks_final_sent(self):\n+        \"\"\"Split-overflow path: if at least one chunk lands on done frame,\n+        we did deliver the final answer — _final_response_sent must be True.\"\"\"\n+        adapter = MagicMock()\n+        adapter.send = AsyncMock(side_effect=[\n+            SimpleNamespace(success=True, message_id=\"msg_1\"),\n+            SimpleNamespace(success=True, message_id=\"msg_2\"),\n+        ])\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=True),\n+        )\n+        adapter.MAX_MESSAGE_LENGTH = 100\n+        adapter.truncate_message = MagicMock(\n+            side_effect=lambda text, limit: [text[:limit], text[limit:]],\n+        )\n+\n+        config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)\n+        consumer = GatewayStreamConsumer(adapter, \"chat_123\", config)\n+\n+        long_text = \"x\" * 200\n+        consumer.on_delta(long_text)\n+        task = asyncio.create_task(consumer.run())\n+        await asyncio.sleep(0.05)\n+        consumer.finish()\n+        await task\n+\n+        assert consumer._final_response_sent is True\n+\n+\n class TestInterimCommentaryMessages:\n     @pytest.mark.asyncio\n     async def test_commentary_message_stays_separate_from_final_stream(self):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}