{"repo":"NousResearch/hermes-agent","public_id":"13248","display_label":"Gateway silence handling and empty-response retry fixes","summary":"These issues and PRs address how the gateway handles intentional silence tokens and empty or reasoning-only responses, particularly fixing retry loops that incorrectly re-inflated or retried deliberate no-reply signals in group chats and other contexts.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46080},"resolution":{"state":"resolved","by":"pr-46080"},"freshness":{"state":"verified","last_verified_at":"2026-06-14T10:25:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":32861,"pr_number":46080,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":13248,"title":"Empty-response retry loop on claude-opus-4-7 in Slack group threads (non-@mention discussion messages)","url":null,"state":"closed"},{"kind":"pr","number":13004,"title":"feat(gateway/discord): add strict_mention config option","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":13252,"title":"fix(gateway): honor group-chat silence in empty-response retry loop (#13248)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":37940,"title":"fix(gateway): make NO_REPLY a first-class silence token; don't re-inflate intentional silence (#13248)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"293c04fef","number":0},{"kind":"pr","value":"#46080","number":46080}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":46080,"title":"fix(gateway): suppress exact silence tokens without mutating history","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/response_filters.py","gateway/run.py","scripts/release.py","tests/gateway/test_gateway_silence_tokens.py","tests/gateway/test_response_filters.py"],"fix_hunks":[{"file":"gateway/response_filters.py","hunks":["@@ -0,0 +1,53 @@\n+\"\"\"Gateway response filtering helpers.\n+\n+These helpers operate at the gateway boundary: they decide whether a completed\n+agent turn should be delivered to the chat, not what should be persisted in the\n+conversation history.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Any\n+\n+# Canonical model-emitted control token for intentional silence.\n+SILENT_REPLY_TOKEN = \"[redacted:secret-assignment]\"\n+\n+# Exact whole-response markers that mean \"the agent intentionally chose not to\n+# reply\".  Keep this list small and explicit; arbitrary empty output remains an\n+# error/empty-response path, not silence.\n+LIVE_GATEWAY_SILENT_MARKERS = frozenset({\n+    \"[SILENT]\",\n+    \"SILENT\",\n+    \"NO_REPLY\",\n+    \"NO REPLY\",\n+})\n+\n+\n+def _canonical_silence_candidate(text: str) -> str:\n+    return \" \".join(text.strip().upper().split())\n+\n+\n+def is_intentional_silence_response(response: Any) -> bool:\n+    \"\"\"Return True only when ``response`` is exactly a silence marker.\n+\n+    Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be\n+    delivered normally.  A blank response is also not silence; blank output is\n+    handled by the empty-response failure path.\n+    \"\"\"\n+    if not isinstance(response, str):\n+        return False\n+    stripped = response.strip()\n+    if not stripped:\n+        return False\n+    if len(stripped) > 64:\n+        return False\n+    return _canonical_silence_candidate(stripped) in LIVE_GATEWAY_SILENT_MARKERS\n+\n+\n+def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:\n+    \"\"\"Silence markers suppress delivery only for successful agent turns.\"\"\"\n+    if not isinstance(agent_result, dict):\n+        return False\n+    if agent_result.get(\"failed\"):\n+        return False\n+    return is_intentional_silence_response(response)"]},{"file":"gateway/run.py","hunks":["@@ -8642,13 +8642,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 return None\n \n             response = agent_result.get(\"final_response\") or \"\"\n+            try:\n+                from gateway.response_filters import is_intentional_silence_agent_result\n+                _intentional_silence = is_intentional_silence_agent_result(\n+                    agent_result, response,\n+                )\n+            except Exception:\n+                _intentional_silence = False\n \n             # Convert the agent's internal \"(empty)\" sentinel into a\n             # user-friendly message.  \"(empty)\" means the model failed to\n             # produce visible content after exhausting all retries (nudge,\n             # prefill, empty-retry, fallback).  Sending the raw sentinel\n             # looks like a bug; a short explanation is more helpful.\n-            if response == \"(empty)\":\n+            if response == \"(empty)\" and not _intentional_silence:\n                 response = (\n                     \"⚠️ The model returned no response after processing tool \"\n                     \"results. This can happen with some models — try again or \"","@@ -8684,10 +8691,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n \n             # Normalize empty responses: surface errors, partial failures, and\n             # the case where agent did work but returned no text. Fix for #18765.\n-            response = _normalize_empty_agent_response(\n-                agent_result, response, history_len=len(history),\n-            )\n-            response = _sanitize_gateway_final_response(source.platform, response)\n+            if not _intentional_silence:\n+                response = _normalize_empty_agent_response(\n+                    agent_result, response, history_len=len(history),\n+                )\n+                response = _sanitize_gateway_final_response(source.platform, response)\n \n             # Ordering contract: the agent thread already updated the contextvar\n             # in conversation_compression.py; propagate to SessionEntry + _save().","@@ -8711,7 +8719,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 )\n             except Exception:\n                 _show_reasoning_effective = getattr(self, \"_show_reasoning\", False)\n-            if _show_reasoning_effective and response:\n+            if _show_reasoning_effective and response and not _intentional_silence:\n                 last_reasoning = agent_result.get(\"last_reasoning\")\n                 if last_reasoning:\n                     # Collapse long reasoning to keep messages readable","@@ -8741,7 +8749,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             except Exception as _footer_err:\n                 logger.debug(\"runtime_footer build failed: %s\", _footer_err)\n                 _footer_line = \"\"\n-            if _footer_line and response and not agent_result.get(\"already_sent\"):\n+            if _footer_line and response and not agent_result.get(\"already_sent\") and not _intentional_silence:\n                 response = f\"{response}\\n\\n{_footer_line}\"\n \n             # Emit agent:end hook","@@ -8975,6 +8983,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 last_prompt_tokens=[redacted:secret-assignment]\"last_prompt_tokens\", 0),\n             )\n \n+            # Intentional silence is a delivery decision, not a transcript\n+            # mutation.  The agent's [SILENT]/NO_REPLY assistant turn above is\n+            # still persisted in session history so later turns keep normal\n+            # user/assistant alternation; only the outbound chat delivery is\n+            # suppressed.\n+            if _intentional_silence:\n+                logger.info(\n+                    \"Suppressing intentional silence marker for session %s\",\n+                    session_entry.session_id,\n+                )\n+                response = \"\"\n+\n             # Auto voice reply: send TTS audio before the text response\n             _already_sent = bool(agent_result.get(\"already_sent\"))\n             if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent):"]},{"file":"scripts/release.py","hunks":["@@ -82,6 +82,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"aldoeliacim\",\n     \"[redacted:email]\": \"MaxFreedomPollard\",\n     \"[redacted:email]\": \"achaljhawar\",\n     \"[redacted:email]\": \"claytonchew\","]},{"file":"tests/gateway/test_gateway_silence_tokens.py","hunks":["@@ -0,0 +1,165 @@\n+\"\"\"Gateway intentional-silence token behavior.\"\"\"\n+\n+from datetime import datetime\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+import gateway.run as gateway_run\n+from gateway.config import GatewayConfig, Platform\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource\n+from gateway.response_filters import (\n+    is_intentional_silence_agent_result,\n+    is_intentional_silence_response,\n+)\n+\n+\n+def _source():\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_type=\"group\",\n+        user_id=\"12345\",\n+    )\n+\n+\n+def _event():\n+    return MessageEvent(\n+        text=\"side chatter\",\n+        source=_source(),\n+        message_id=\"msg-42\",\n+    )\n+\n+\n+def _runner(monkeypatch, tmp_path):\n+    runner = gateway_run.GatewayRunner(GatewayConfig())\n+    runner.adapters = {}\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._is_user_authorized = lambda _source: True\n+    runner._set_session_env = lambda _context: None\n+    runner._handle_active_session_busy_message = AsyncMock(return_value=False)\n+    runner._session_db = MagicMock()\n+    runner._recover_telegram_topic_thread_id = lambda _source: None\n+    runner._cache_session_source = lambda _key, _source: None\n+    runner._is_session_run_current = lambda _key, _gen: True\n+    runner._reply_anchor_for_event = lambda _event: None\n+    runner._get_guild_id = lambda _event: None\n+    runner._should_send_voice_reply = lambda *_a, **_kw: False\n+    runner.hooks = MagicMock()\n+    runner.hooks.emit = AsyncMock()\n+\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = SessionEntry(\n+        session_key=\"agent:main:telegram:group:-1001:12345\",\n+        session_id=\"sess-silent\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"group\",\n+    )\n+    runner.session_store.load_transcript.return_value = []\n+    runner.session_store.append_to_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(\n+        gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"fake\"}\n+    )\n+    monkeypatch.setattr(\n+        \"agent.model_metadata.get_model_context_length\",\n+        lambda *_args, **_kwargs: 100_000,\n+    )\n+    return runner\n+\n+\n+def test_exact_silence_tokens_are_intentional_silence():\n+    for token in (\"[SILENT]\", \" SILENT \", \"NO_REPLY\", \"no reply\"):\n+        assert is_intentional_silence_response(token)\n+\n+\n+def test_blank_and_prose_mentions_are_not_silence():\n+    assert not is_intentional_silence_response(\"\")\n+    assert not is_intentional_silence_response(\"Use NO_REPLY when no answer is needed.\")\n+    assert not is_intentional_silence_response(\"The reply was [SILENT], intentionally.\")\n+\n+\n+def test_failed_agent_result_never_counts_as_intentional_silence():\n+    assert is_intentional_silence_agent_result({\"failed\": False}, \"NO_REPLY\")\n+    assert not is_intentional_silence_agent_result({\"failed\": True}, \"NO_REPLY\")\n+\n+\n[redacted:email]\n+async def test_silence_token_suppresses_delivery_but_preserves_transcript(monkeypatch, tmp_path):\n+    runner = _runner(monkeypatch, tmp_path)\n+    runner._run_agent = AsyncMock(return_value={\n+        \"final_response\": \"[SILENT]\",\n+        \"messages\": [\n+            {\"role\": \"user\", \"content\": \"side chatter\"},\n+            {\"role\": \"assistant\", \"content\": \"[SILENT]\"},\n+        ],\n+        \"tools\": [],\n+        \"history_offset\": 0,\n+        \"last_prompt_tokens\": 0,\n+        \"api_calls\": 1,\n+        \"failed\": False,\n+    })\n+\n+    response = await runner._handle_message_with_agent(\n+        _event(), _source(), \"agent:main:telegram:group:-1001:12345\", 1\n+    )\n+\n+    assert response == \"\"\n+    appended = [call.args[1] for call in runner.session_store.append_to_transcript.call_args_list]\n+    assert {\"role\": \"assistant\", \"content\": \"[SILENT]\"}.items() <= appended[-1].items()\n+    assert [msg[\"role\"] for msg in appended if msg.get(\"role\") in {\"user\", \"assistant\"}] == [\"user\", \"assistant\"]\n+\n+\n[redacted:email]\n+async def test_empty_success_still_gets_empty_response_warning(monkeypatch, tmp_path):\n+    runner = _runner(monkeypatch, tmp_path)\n+    runner._run_agent = AsyncMock(return_value={\n+        \"final_response\": \"\",\n+        \"messages\": [\n+            {\"role\": \"user\", \"content\": \"question\"},\n+            {\"role\": \"assistant\", \"content\": \"\"},\n+        ],\n+        \"tools\": [],\n+        \"history_offset\": 0,\n+        \"last_prompt_tokens\": 0,\n+        \"api_calls\": 1,\n+        \"failed\": False,\n+    })\n+\n+    response = await runner._handle_message_with_agent(\n+        _event(), _source(), \"agent:main:telegram:group:-1001:12345\", 1\n+    )\n+\n+    assert \"no response was generated\" in response\n+\n+\n[redacted:email]\n+async def test_prose_mentioning_silence_token_is_delivered(monkeypatch, tmp_path):\n+    runner = _runner(monkeypatch, tmp_path)\n+    text = \"Use [SILENT] when no answer is needed.\"\n+    runner._run_agent = AsyncMock(return_value={\n+        \"final_response\": text,\n+        \"messages\": [\n+            {\"role\": \"user\", \"content\": \"question\"},\n+            {\"role\": \"assistant\", \"content\": text},\n+        ],\n+        \"tools\": [],\n+        \"history_offset\": 0,\n+        \"last_prompt_tokens\": 0,\n+        \"api_calls\": 1,\n+        \"failed\": False,\n+    })\n+\n+    response = await runner._handle_message_with_agent(\n+        _event(), _source(), \"agent:main:telegram:group:-1001:12345\", 1\n+    )\n+\n+    assert response == text"]},{"file":"tests/gateway/test_response_filters.py","hunks":["@@ -0,0 +1,20 @@\n+from gateway.response_filters import (\n+    is_intentional_silence_agent_result,\n+    is_intentional_silence_response,\n+)\n+\n+\n+def test_exact_silence_tokens_are_intentional_silence():\n+    for token in (\"[SILENT]\", \" SILENT \", \"NO_REPLY\", \"no reply\"):\n+        assert is_intentional_silence_response(token)\n+\n+\n+def test_blank_and_prose_mentions_are_not_silence():\n+    assert not is_intentional_silence_response(\"\")\n+    assert not is_intentional_silence_response(\"Use NO_REPLY when no answer is needed.\")\n+    assert not is_intentional_silence_response(\"The reply was [SILENT], intentionally.\")\n+\n+\n+def test_failed_agent_result_never_counts_as_intentional_silence():\n+    assert is_intentional_silence_agent_result({\"failed\": False}, \"NO_REPLY\")\n+    assert not is_intentional_silence_agent_result({\"failed\": True}, \"NO_REPLY\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}