# NousResearch/hermes-agent · Filter outbound silence-narration messages at gateway

## Open now

- Nothing open on GitHub.

These items address dropping 'silence narration' messages before they are sent outbound through the gateway, as an anti-loop control measure. The group appears to contain duplicate or closely related issues and pull requests around the same fix.

## TL;DR

- 4 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #34853. Verify it addresses your case.
- **Recommended:** read PR #34853 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-34853`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.73`: the share of member 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.

## Closed / duplicate issues

- #34615: gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)
- #34616: gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)

## Prior & competing PRs

- **#34853: fix(gateway): drop outbound silence-narration messages pre-send**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `gateway/delivery.py`, `tests/gateway/test_delivery_silence_filter.py`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -474,6 +474,13 @@ class GatewayConfig:
         
         # Delivery settings
         always_log_local: bool = True  # Always save cron outputs to local files
    +    # Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
    +    # ".") pre-send. These are model hallucinations emitted when a persona has
    +    # nothing actionable to say; in bot-to-bot channels they mirror back and
    +    # forth, burning tokens and crashing models. Substrate-level guard that
    +    # survives SOUL.md/prompt drift across providers. Opt out with False for
    +    # raw passthrough.
    +    filter_silence_narration: bool = True
     
         # STT settings
         stt_enabled: bool = True  # Whether to auto-transcribe inbound voice messages
    ```

    ```diff
    @@ -582,6 +589,7 @@ def to_dict(self) -> Dict[str, Any]:
                 "quick_commands": self.quick_commands,
                 "sessions_dir": str(self.sessions_dir),
                 "always_log_local": self.always_log_local,
    +            "filter_silence_narration": self.filter_silence_narration,
                 "stt_enabled": self.stt_enabled,
                 "group_sessions_per_user": self.group_sessions_per_user,
                 "thread_sessions_per_user": self.thread_sessions_per_user,
    ```

    ```diff
    @@ -650,6 +658,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
                 quick_commands=quick_commands,
                 sessions_dir=sessions_dir,
                 always_log_local=_coerce_bool(data.get("always_log_local"), True),
    +            filter_silence_narration=_coerce_bool(
    +                data.get("filter_silence_narration"), True
    +            ),
                 stt_enabled=_coerce_bool(stt_enabled, True),
                 group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
                 thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
    ```

    ```diff
    @@ -757,6 +768,11 @@ def load_gateway_config() -> GatewayConfig:
                 if "always_log_local" in yaml_cfg:
                     gw_data["always_log_local"] = yaml_cfg["always_log_local"]
     
    +            if "filter_silence_narration" in yaml_cfg:
    +                gw_data["filter_silence_narration"] = yaml_cfg[
    +                    "filter_silence_narration"
    +                ]
    +
                 if "unauthorized_dm_behavior" in yaml_cfg:
                     gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
                         yaml_cfg.get("unauthorized_dm_behavior"),
    ```
  - `gateway/delivery.py`

    ```diff
    @@ -9,6 +9,8 @@
     """
     
     import logging
    +import os
    +import re
     from pathlib import Path
     from datetime import datetime
     from dataclasses import dataclass
    ```

    ```diff
    @@ -21,6 +23,32 @@
     MAX_PLATFORM_OUTPUT = 4000
     TRUNCATED_VISIBLE = 3800
     
    +# Matches strings that are *only* a "silence" narration with optional markdown
    +# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
    +# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
    +# wild. Anchored to start/end so substantive messages that merely *contain* the
    +# word "silent" are never matched.
    +_SILENCE_NARRATION = re.compile(
    +    r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
    +    r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
    +    re.IGNORECASE,
    +)
    +
    +
    +def _is_silence_narration(content: Optional[str]) -> bool:
    +    """Return True when ``content`` is *only* a silence-narration token.
    +
    +    Length-guarded (real messages are longer) and anchored to the whole string
    +    so legitimate prose like "The deployment ran silently" or "Silence is
    +    golden — here is the plan..." is never flagged.
    +    """
    +    if not content:
    +        return False
    +    stripped = content.strip()
    +    if not stripped or len(stripped) > 64:  # length guard
    +        return False
    +    return bool(_SILENCE_NARRATION.match(stripped))
    +
     from .config import Platform, GatewayConfig
     from .session import SessionSource
     
    ```

    ```diff
    @@ -261,6 +289,18 @@ def _save_full_output(self, content: str, job_id: str) -> Path:
             path.write_text(content)
             return path
     
    +    def _filter_silence_narration_enabled(self) -> bool:
    +        """Whether the outbound silence-narration filter is active.
    +
    +        ``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
    +        otherwise the ``gateway.filter_silence_narration`` config flag wins
    +        (default True).
    +        """
    +        env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
    +        if env is not None:
    +            return env.strip().lower() in ("1", "true", "yes", "on")
    +        return bool(getattr(self.config, "filter_silence_narration", True))
    +
         async def _deliver_to_platform(
             self,
             target: DeliveryTarget,
    ```

    ```diff
    @@ -286,6 +326,27 @@ async def _deliver_to_platform(
                     + f"\n\n... [truncated, full output saved to {saved_path}]"
                 )
             
    +        # Substrate-level anti-loop guard: drop hallucinated "silence narration"
    +        # (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
    +        # In bot-to-bot channels these tokens mirror back and forth until a
    +        # model crashes with "no content after all retries". Behavioral prompt
    +        # rules drift across providers; this single chokepoint covers every
    +        # platform adapter regardless of which persona's prompt failed.
    +        # Local/file delivery (_deliver_local) is a separate path and is never
    +        # filtered — saved silence has no loop risk.
    +        if self._filter_silence_narration_enabled() and _is_silence_narration(content):
    +            logger.warning(
    +                "Dropped silence-narration outbound to %s (chat=%s): %r",
    +                target.platform.value,
    +                target.chat_id,
    +                content[:40],
    +            )
    +            return {
    +                "success": True,
    +                "filtered": "silence_narration",
    +                "delivered": False,
    +            }
    +
             send_metadata = dict(metadata or {})
             is_named_telegram_private_topic = False
             named_telegram_private_topic_name: Optional[str] = None
    ```
  - `tests/gateway/test_delivery_silence_filter.py`

    ```diff
    @@ -0,0 +1,202 @@
    +"""Tests for the outbound silence-narration filter (anti-loop control).
    +
    +See the gateway delivery path: hallucinated "silence" tokens like ``*(silent)*``
    +are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning
    +loop that crashes a model with "no content after all retries".
    +"""
    +
    +import pytest
    +
    +from gateway.config import GatewayConfig, Platform
    +from gateway.delivery import (
    +    DeliveryRouter,
    +    DeliveryTarget,
    +    _is_silence_narration,
    +)
    +
    +
    +# --- Truth table -----------------------------------------------------------
    +
    +POSITIVE_CASES = [
    +    "*(silent)*",
    +    "*Silence.*",
    +    "🔇",
    +    ".",
    +    "…",
    +    "...",
    +    "(silent)",
    +    "_silent_",
    +    "silent",
    +    " *(silent)* ",
    +    "`silent`",
    +    "~silent~",
    +    "Silence",
    +    "no response",
    +    "No Reply.",
    +]
    +
    +NEGATIVE_CASES = [
    +    "Silence is golden — here is the plan...",
    +    "Silent install completed",
    +    "The deployment ran silently in the background",
    +    "ok",
    +    "👍",
    +    "Here is the result:\n\n- item one\n- item two",
    +    "I have nothing to add, but here is why: the build is green.",
    +    "silently",  # word boundary — trailing letters mean it isn't a bare token
    +    "no responses were collected from the survey",
    +    # A 64+ char string that opens with a silence token must not be dropped.
    +    "silent " + "x" * 70,
    +    "",
    +    "   ",
    +]
    +
    +
    [redacted:email]("content", POSITIVE_CASES)
    +def test_is_silence_narration_positive(content):
    +    assert _is_silence_narration(content) is True
    +
    +
    [redacted:email]("content", NEGATIVE_CASES)
    +def test_is_silence_narration_negative(content):
    +    assert _is_silence_narration(content) is False
    +
    +
    +def test_is_silence_narration_none_safe():
    +    assert _is_silence_narration(None) is False
    +
    +
    +def test_length_guard_rejects_long_strings():
    +    # Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.
    +    assert _is_silence_narration("." * 65) is False
    +    assert _is_silence_narration("." * 64) is True
    +
    +
    +# --- Integration through DeliveryRouter ------------------------------------
    +
    +class RecordingAdapter:
    +    def __init__(self):
    +        self.calls = []
    +
    +    async def send(self, chat_id, content, metadata=None):
    +        self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
    +        return {"success": True}
    +
    +
    [redacted:email]
    +async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
    +    adapter = RecordingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:99887766")
    +
    +    result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
    +
    +    assert adapter.calls == []  # adapter.send never invoked
    +    assert result == {
    +        "success": True,
    +        "filtered": "silence_narration",
    +        "delivered": False,
    +    }
    +
    +
    [redacted:email]
    +async def test_real_message_is_delivered(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
    +    adapter = RecordingAdapter()
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:99887766")
    +
    +    result = await router._deliver_to_platform(
    +        target, "Silence is golden — here is the plan...", metadata=None
    +    )
    +
    +    assert len(adapter.calls) == 1
    +    assert adapter.calls[0]["content"] == "Silence is golden — here is the plan..."
    +    assert result == {"success": True}
    +
    +
    [redacted:email]
    +async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
    +    adapter = RecordingAdapter()
    +    config = GatewayConfig(filter_silence_narration=False)
    +    router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:99887766")
    +
    +    result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
    +
    +    assert len(adapter.calls) == 1
    +    assert adapter.calls[0]["content"] == "*(silent)*"
    +    assert result == {"success": True}
    +
    +
    [redacted:email]
    +async def test_env_override_disables_filter(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "0")
    +    adapter = RecordingAdapter()
    +    # Config default is True, but env override wins.
    +    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:99887766")
    +
    +    result = await router._deliver_to_platform(target, "🔇", metadata=None)
    +
    +    assert len(adapter.calls) == 1
    +    assert result == {"success": True}
    +
    +
    [redacted:email]
    +async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "1")
    +    adapter = RecordingAdapter()
    +    # Config says off, env override forces on.
    +    config = GatewayConfig(filter_silence_narration=False)
    +    router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
    +    target = DeliveryTarget.parse("discord:99887766")
    +
    +    result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
    +
    +    assert adapter.calls == []
    +    assert result["filtered"] == "silence_narration"
    +
    +
    [redacted:email]
    +async def test_local_delivery_not_filtered(tmp_path, monkeypatch):
    +    monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
    +    monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
    +    router = DeliveryRouter(GatewayConfig(), adapters={})
    +
    +    results = await router.deliver(
    +        content="*(silent)*",
    +        targets=[DeliveryTarget.parse("local")],
    +        job_id="silence-job",
    +    )
    +
    +    # Local path saved the file (no loop risk) and was not filtered.
    +    local_result = results["local"]
    +    assert local_result["success"] is True
    +    saved_path = local_result["result"]["path"]
    +    assert saved_path.endswith(".md")
    +
    +
    +# --- Config round-trip ------------------------------------------------------
    +
    +def test_config_flag_defaults_true():
    +    assert GatewayConfig().filter_silence_narration is True
    +
    +
    +def test_config_from_dict_parses_flag():
    +    cfg = GatewayConfig.from_dict({"filter_silence_narration": False})
    +    assert cfg.filter_silence_narration is False
    +
    +
    +def test_config_to_dict_roundtrip():
    +    cfg = GatewayConfig(filter_silence_narration=False)
    +    assert cfg.to_dict()["filter_silence_narration"] is False
    +    restored = GatewayConfig.from_dict(cfg.to_dict())
    +    assert restored.filter_silence_narration is False
    ```
- **#34621: fix(gateway): drop outbound silence-narration messages pre-send**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `45bc65abbe4767b327cea3b44300a25e5e7d97aa`, `#34853`

## Fix status

- Merged to main in PR #34853 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-30`.
- Subsystem: `gateway`
