Gateway silence handling and empty-response retry fixes
NousResearch/hermes-agent · cluster 13248 · gateway
Data as of
Nothing open on GitHub. Fix merged in #46080. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #46080. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #46080. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#46080 fix(gateway): suppress exact silence tokens without mutating history
files:
gateway/response_filters.py,gateway/run.py,scripts/release.py,tests/gateway/test_gateway_silence_tokens.py,tests/gateway/test_response_filters.pyShow the diff
-
gateway/response_filters.py@@ -0,0 +1,53 @@ +"""Gateway response filtering helpers. + +These helpers operate at the gateway boundary: they decide whether a completed +agent turn should be delivered to the chat, not what should be persisted in the +conversation history. +""" + +from __future__ import annotations + +from typing import Any + +# Canonical model-emitted control token for intentional silence. +SILENT_REPLY_TOKEN = "[redacted:secret-assignment]" + +# Exact whole-response markers that mean "the agent intentionally chose not to +# reply". Keep this list small and explicit; arbitrary empty output remains an +# error/empty-response path, not silence. +LIVE_GATEWAY_SILENT_MARKERS = frozenset({ + "[SILENT]", + "SILENT", + "NO_REPLY", + "NO REPLY", +}) + + +def _canonical_silence_candidate(text: str) -> str: + return " ".join(text.strip().upper().split()) + + +def is_intentional_silence_response(response: Any) -> bool: + """Return True only when ``response`` is exactly a silence marker. + + Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be + delivered normally. A blank response is also not silence; blank output is + handled by the empty-response failure path. + """ + if not isinstance(response, str): + return False + stripped = response.strip() + if not stripped: + return False + if len(stripped) > 64: + return False + return _canonical_silence_candidate(stripped) in LIVE_GATEWAY_SILENT_MARKERS + + +def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool: + """Silence markers suppress delivery only for successful agent turns.""" + if not isinstance(agent_result, dict): + return False + if agent_result.get("failed"): + return False + return is_intentional_silence_response(response) -
gateway/run.py@@ -8642,13 +8642,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g return None response = agent_result.get("final_response") or "" + try: + from gateway.response_filters import is_intentional_silence_agent_result + _intentional_silence = is_intentional_silence_agent_result( + agent_result, response, + ) + except Exception: + _intentional_silence = False # Convert the agent's internal "(empty)" sentinel into a # user-friendly message. "(empty)" means the model failed to # produce visible content after exhausting all retries (nudge, # prefill, empty-retry, fallback). Sending the raw sentinel # looks like a bug; a short explanation is more helpful. - if response == "(empty)": + if response == "(empty)" and not _intentional_silence: response = ( "⚠️ The model returned no response after processing tool " "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 # Normalize empty responses: surface errors, partial failures, and # the case where agent did work but returned no text. Fix for #18765. - response = _normalize_empty_agent_response( - agent_result, response, history_len=len(history), - ) - response = _sanitize_gateway_final_response(source.platform, response) + if not _intentional_silence: + response = _normalize_empty_agent_response( + agent_result, response, history_len=len(history), + ) + response = _sanitize_gateway_final_response(source.platform, response) # Ordering contract: the agent thread already updated the contextvar # 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 ) except Exception: _show_reasoning_effective = getattr(self, "_show_reasoning", False) - if _show_reasoning_effective and response: + if _show_reasoning_effective and response and not _intentional_silence: last_reasoning = agent_result.get("last_reasoning") if last_reasoning: # 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 except Exception as _footer_err: logger.debug("runtime_footer build failed: %s", _footer_err) _footer_line = "" - if _footer_line and response and not agent_result.get("already_sent"): + if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence: response = f"{response}\n\n{_footer_line}" # Emit agent:end hook @@ -8975,6 +8983,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g last_prompt_tokens=[redacted:secret-assignment]"last_prompt_tokens", 0), ) + # Intentional silence is a delivery decision, not a transcript + # mutation. The agent's [SILENT]/NO_REPLY assistant turn above is + # still persisted in session history so later turns keep normal + # user/assistant alternation; only the outbound chat delivery is + # suppressed. + if _intentional_silence: + logger.info( + "Suppressing intentional silence marker for session %s", + session_entry.session_id, + ) + response = "" + # Auto voice reply: send TTS audio before the text response _already_sent = bool(agent_result.get("already_sent")) if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent): -
scripts/release.py@@ -82,6 +82,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "aldoeliacim", "[redacted:email]": "MaxFreedomPollard", "[redacted:email]": "achaljhawar", "[redacted:email]": "claytonchew", -
tests/gateway/test_gateway_silence_tokens.py@@ -0,0 +1,165 @@ +"""Gateway intentional-silence token behavior.""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import GatewayConfig, Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource +from gateway.response_filters import ( + is_intentional_silence_agent_result, + is_intentional_silence_response, +) + + +def _source(): + return SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001", + chat_type="group", + user_id="12345", + ) + + +def _event(): + return MessageEvent( + text="side chatter", + source=_source(), + message_id="msg-42", + ) + + +def _runner(monkeypatch, tmp_path): + runner = gateway_run.GatewayRunner(GatewayConfig()) + runner.adapters = {} + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._handle_active_session_busy_message = AsyncMock(return_value=False) + runner._session_db = MagicMock() + runner._recover_telegram_topic_thread_id = lambda _source: None + runner._cache_session_source = lambda _key, _source: None + runner._is_session_run_current = lambda _key, _gen: True + runner._reply_anchor_for_event = lambda _event: None + runner._get_guild_id = lambda _event: None + runner._should_send_voice_reply = lambda *_a, **_kw: False + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = SessionEntry( + session_key="agent:main:telegram:group:-1001:12345", + session_id="sess-silent", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="group", + ) + runner.session_store.load_transcript.return_value = [] + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"} + ) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 100_000, + ) + return runner + + +def test_exact_silence_tokens_are_intentional_silence(): + for token in ("[SILENT]", " SILENT ", "NO_REPLY", "no reply"): + assert is_intentional_silence_response(token) + + +def test_blank_and_prose_mentions_are_not_silence(): + assert not is_intentional_silence_response("") + assert not is_intentional_silence_response("Use NO_REPLY when no answer is needed.") + assert not is_intentional_silence_response("The reply was [SILENT], intentionally.") + + +def test_failed_agent_result_never_counts_as_intentional_silence(): + assert is_intentional_silence_agent_result({"failed": False}, "NO_REPLY") + assert not is_intentional_silence_agent_result({"failed": True}, "NO_REPLY") + + [redacted:email] +async def test_silence_token_suppresses_delivery_but_preserves_transcript(monkeypatch, tmp_path): + runner = _runner(monkeypatch, tmp_path) + runner._run_agent = AsyncMock(return_value={ + "final_response": "[SILENT]", + "messages": [ + {"role": "user", "content": "side chatter"}, + {"role": "assistant", "content": "[SILENT]"}, + ], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + "api_calls": 1, + "failed": False, + }) + + response = await runner._handle_message_with_agent( + _event(), _source(), "agent:main:telegram:group:-1001:12345", 1 + ) + + assert response == "" + appended = [call.args[1] for call in runner.session_store.append_to_transcript.call_args_list] + assert {"role": "assistant", "content": "[SILENT]"}.items() <= appended[-1].items() + assert [msg["role"] for msg in appended if msg.get("role") in {"user", "assistant"}] == ["user", "assistant"] + + [redacted:email] +async def test_empty_success_still_gets_empty_response_warning(monkeypatch, tmp_path): + runner = _runner(monkeypatch, tmp_path) + runner._run_agent = AsyncMock(return_value={ + "final_response": "", + "messages": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": ""}, + ], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + "api_calls": 1, + "failed": False, + }) + + response = await runner._handle_message_with_agent( + _event(), _source(), "agent:main:telegram:group:-1001:12345", 1 + ) + + assert "no response was generated" in response + + [redacted:email] +async def test_prose_mentioning_silence_token_is_delivered(monkeypatch, tmp_path): + runner = _runner(monkeypatch, tmp_path) + text = "Use [SILENT] when no answer is needed." + runner._run_agent = AsyncMock(return_value={ + "final_response": text, + "messages": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": text}, + ], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + "api_calls": 1, + "failed": False, + }) + + response = await runner._handle_message_with_agent( + _event(), _source(), "agent:main:telegram:group:-1001:12345", 1 + ) + + assert response == text -
tests/gateway/test_response_filters.py@@ -0,0 +1,20 @@ +from gateway.response_filters import ( + is_intentional_silence_agent_result, + is_intentional_silence_response, +) + + +def test_exact_silence_tokens_are_intentional_silence(): + for token in ("[SILENT]", " SILENT ", "NO_REPLY", "no reply"): + assert is_intentional_silence_response(token) + + +def test_blank_and_prose_mentions_are_not_silence(): + assert not is_intentional_silence_response("") + assert not is_intentional_silence_response("Use NO_REPLY when no answer is needed.") + assert not is_intentional_silence_response("The reply was [SILENT], intentionally.") + + +def test_failed_agent_result_never_counts_as_intentional_silence(): + assert is_intentional_silence_agent_result({"failed": False}, "NO_REPLY") + assert not is_intentional_silence_agent_result({"failed": True}, "NO_REPLY")
-
3 PRs closed without merging
From the close comments: closed without comment 1 · merged via another change 1 · other 1
- #13004 feat(gateway/discord): add strict_mention config option · closed without comment
- #13252 fix(gateway): honor group-chat silence in empty-response retry loop (#13248) · other
-
#37940 fix(gateway): make NO_REPLY a first-class silence token; don't re-inflate intentional silence (#13248) · merged via another change →
293c04f,#46080
Closed issues · 1
- Closed#13248Empty-response retry loop on claude-opus-4-7 in Slack group threads (non-@mention discussion messages)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #46080
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 14 Jun 2026