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

0open issues
0open PRs
5items grouped
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

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.

  • #46080 fix(gateway): suppress exact silence tokens without mutating history

    merged to main

    files: gateway/response_filters.py, gateway/run.py, scripts/release.py, tests/gateway/test_gateway_silence_tokens.py, tests/gateway/test_response_filters.py

    Show 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.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
gateway
Merged PRs
#46080
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
14 Jun 2026

For your agent

The same facts, typed.