{"repo":"NousResearch/hermes-agent","public_id":"34615","display_label":"Filter outbound silence-narration messages at gateway","summary":"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.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34853},"resolution":{"state":"partially_resolved","by":"pr-34853"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T02:06:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.7266666666666666},"fix_edges":[{"issue_number":34615,"pr_number":34853,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34616,"pr_number":34853,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":34615,"title":"gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)","url":null,"state":"closed"},{"kind":"issue","number":34616,"title":"gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)","url":null,"state":"closed"},{"kind":"pr","number":34621,"title":"fix(gateway): drop outbound silence-narration messages pre-send","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"45bc65abbe4767b327cea3b44300a25e5e7d97aa","number":0},{"kind":"pr","value":"#34853","number":34853}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34853,"title":"fix(gateway): drop outbound silence-narration messages pre-send","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/delivery.py","tests/gateway/test_delivery_silence_filter.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -474,6 +474,13 @@ class GatewayConfig:\n     \n     # Delivery settings\n     always_log_local: bool = True  # Always save cron outputs to local files\n+    # Drop outbound \"silence narration\" messages (e.g. *(silent)*, 🔇, a bare\n+    # \".\") pre-send. These are model hallucinations emitted when a persona has\n+    # nothing actionable to say; in bot-to-bot channels they mirror back and\n+    # forth, burning tokens and crashing models. Substrate-level guard that\n+    # survives SOUL.md/prompt drift across providers. Opt out with False for\n+    # raw passthrough.\n+    filter_silence_narration: bool = True\n \n     # STT settings\n     stt_enabled: bool = True  # Whether to auto-transcribe inbound voice messages","@@ -582,6 +589,7 @@ def to_dict(self) -> Dict[str, Any]:\n             \"quick_commands\": self.quick_commands,\n             \"sessions_dir\": str(self.sessions_dir),\n             \"always_log_local\": self.always_log_local,\n+            \"filter_silence_narration\": self.filter_silence_narration,\n             \"stt_enabled\": self.stt_enabled,\n             \"group_sessions_per_user\": self.group_sessions_per_user,\n             \"thread_sessions_per_user\": self.thread_sessions_per_user,","@@ -650,6 +658,9 @@ def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n             quick_commands=quick_commands,\n             sessions_dir=sessions_dir,\n             always_log_local=_coerce_bool(data.get(\"always_log_local\"), True),\n+            filter_silence_narration=_coerce_bool(\n+                data.get(\"filter_silence_narration\"), True\n+            ),\n             stt_enabled=_coerce_bool(stt_enabled, True),\n             group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),\n             thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),","@@ -757,6 +768,11 @@ def load_gateway_config() -> GatewayConfig:\n             if \"always_log_local\" in yaml_cfg:\n                 gw_data[\"always_log_local\"] = yaml_cfg[\"always_log_local\"]\n \n+            if \"filter_silence_narration\" in yaml_cfg:\n+                gw_data[\"filter_silence_narration\"] = yaml_cfg[\n+                    \"filter_silence_narration\"\n+                ]\n+\n             if \"unauthorized_dm_behavior\" in yaml_cfg:\n                 gw_data[\"unauthorized_dm_behavior\"] = _normalize_unauthorized_dm_behavior(\n                     yaml_cfg.get(\"unauthorized_dm_behavior\"),"]},{"file":"gateway/delivery.py","hunks":["@@ -9,6 +9,8 @@\n \"\"\"\n \n import logging\n+import os\n+import re\n from pathlib import Path\n from datetime import datetime\n from dataclasses import dataclass","@@ -21,6 +23,32 @@\n MAX_PLATFORM_OUTPUT = 4000\n TRUNCATED_VISIBLE = 3800\n \n+# Matches strings that are *only* a \"silence\" narration with optional markdown\n+# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,\n+# 🔇, a bare \".\", \"…\", and the whitespace/marker-padded variants seen in the\n+# wild. Anchored to start/end so substantive messages that merely *contain* the\n+# word \"silent\" are never matched.\n+_SILENCE_NARRATION = re.compile(\n+    r'^[\\s*_~`]*\\(?\\s*(silent|silence|no\\s+response|no\\s+reply)\\s*\\.?\\)?[\\s*_~`]*$'\n+    r'|^[\\s*_~`]*[\\U0001F507\\.\\u2026]+[\\s*_~`]*$',\n+    re.IGNORECASE,\n+)\n+\n+\n+def _is_silence_narration(content: Optional[str]) -> bool:\n+    \"\"\"Return True when ``content`` is *only* a silence-narration token.\n+\n+    Length-guarded (real messages are longer) and anchored to the whole string\n+    so legitimate prose like \"The deployment ran silently\" or \"Silence is\n+    golden — here is the plan...\" is never flagged.\n+    \"\"\"\n+    if not content:\n+        return False\n+    stripped = content.strip()\n+    if not stripped or len(stripped) > 64:  # length guard\n+        return False\n+    return bool(_SILENCE_NARRATION.match(stripped))\n+\n from .config import Platform, GatewayConfig\n from .session import SessionSource\n ","@@ -261,6 +289,18 @@ def _save_full_output(self, content: str, job_id: str) -> Path:\n         path.write_text(content)\n         return path\n \n+    def _filter_silence_narration_enabled(self) -> bool:\n+        \"\"\"Whether the outbound silence-narration filter is active.\n+\n+        ``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;\n+        otherwise the ``gateway.filter_silence_narration`` config flag wins\n+        (default True).\n+        \"\"\"\n+        env = os.getenv(\"HERMES_FILTER_SILENCE_NARRATION\")\n+        if env is not None:\n+            return env.strip().lower() in (\"1\", \"true\", \"yes\", \"on\")\n+        return bool(getattr(self.config, \"filter_silence_narration\", True))\n+\n     async def _deliver_to_platform(\n         self,\n         target: DeliveryTarget,","@@ -286,6 +326,27 @@ async def _deliver_to_platform(\n                 + f\"\\n\\n... [truncated, full output saved to {saved_path}]\"\n             )\n         \n+        # Substrate-level anti-loop guard: drop hallucinated \"silence narration\"\n+        # (*(silent)*, 🔇, a bare \".\", etc.) before it ever reaches the adapter.\n+        # In bot-to-bot channels these tokens mirror back and forth until a\n+        # model crashes with \"no content after all retries\". Behavioral prompt\n+        # rules drift across providers; this single chokepoint covers every\n+        # platform adapter regardless of which persona's prompt failed.\n+        # Local/file delivery (_deliver_local) is a separate path and is never\n+        # filtered — saved silence has no loop risk.\n+        if self._filter_silence_narration_enabled() and _is_silence_narration(content):\n+            logger.warning(\n+                \"Dropped silence-narration outbound to %s (chat=%s): %r\",\n+                target.platform.value,\n+                target.chat_id,\n+                content[:40],\n+            )\n+            return {\n+                \"success\": True,\n+                \"filtered\": \"silence_narration\",\n+                \"delivered\": False,\n+            }\n+\n         send_metadata = dict(metadata or {})\n         is_named_telegram_private_topic = False\n         named_telegram_private_topic_name: Optional[str] = None"]},{"file":"tests/gateway/test_delivery_silence_filter.py","hunks":["@@ -0,0 +1,202 @@\n+\"\"\"Tests for the outbound silence-narration filter (anti-loop control).\n+\n+See the gateway delivery path: hallucinated \"silence\" tokens like ``*(silent)*``\n+are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning\n+loop that crashes a model with \"no content after all retries\".\n+\"\"\"\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform\n+from gateway.delivery import (\n+    DeliveryRouter,\n+    DeliveryTarget,\n+    _is_silence_narration,\n+)\n+\n+\n+# --- Truth table -----------------------------------------------------------\n+\n+POSITIVE_CASES = [\n+    \"*(silent)*\",\n+    \"*Silence.*\",\n+    \"🔇\",\n+    \".\",\n+    \"…\",\n+    \"...\",\n+    \"(silent)\",\n+    \"_silent_\",\n+    \"silent\",\n+    \" *(silent)* \",\n+    \"`silent`\",\n+    \"~silent~\",\n+    \"Silence\",\n+    \"no response\",\n+    \"No Reply.\",\n+]\n+\n+NEGATIVE_CASES = [\n+    \"Silence is golden — here is the plan...\",\n+    \"Silent install completed\",\n+    \"The deployment ran silently in the background\",\n+    \"ok\",\n+    \"👍\",\n+    \"Here is the result:\\n\\n- item one\\n- item two\",\n+    \"I have nothing to add, but here is why: the build is green.\",\n+    \"silently\",  # word boundary — trailing letters mean it isn't a bare token\n+    \"no responses were collected from the survey\",\n+    # A 64+ char string that opens with a silence token must not be dropped.\n+    \"silent \" + \"x\" * 70,\n+    \"\",\n+    \"   \",\n+]\n+\n+\n[redacted:email](\"content\", POSITIVE_CASES)\n+def test_is_silence_narration_positive(content):\n+    assert _is_silence_narration(content) is True\n+\n+\n[redacted:email](\"content\", NEGATIVE_CASES)\n+def test_is_silence_narration_negative(content):\n+    assert _is_silence_narration(content) is False\n+\n+\n+def test_is_silence_narration_none_safe():\n+    assert _is_silence_narration(None) is False\n+\n+\n+def test_length_guard_rejects_long_strings():\n+    # Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.\n+    assert _is_silence_narration(\".\" * 65) is False\n+    assert _is_silence_narration(\".\" * 64) is True\n+\n+\n+# --- Integration through DeliveryRouter ------------------------------------\n+\n+class RecordingAdapter:\n+    def __init__(self):\n+        self.calls = []\n+\n+    async def send(self, chat_id, content, metadata=None):\n+        self.calls.append({\"chat_id\": chat_id, \"content\": content, \"metadata\": metadata})\n+        return {\"success\": True}\n+\n+\n[redacted:email]\n+async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.delenv(\"HERMES_FILTER_SILENCE_NARRATION\", raising=False)\n+    adapter = RecordingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:99887766\")\n+\n+    result = await router._deliver_to_platform(target, \"*(silent)*\", metadata=None)\n+\n+    assert adapter.calls == []  # adapter.send never invoked\n+    assert result == {\n+        \"success\": True,\n+        \"filtered\": \"silence_narration\",\n+        \"delivered\": False,\n+    }\n+\n+\n[redacted:email]\n+async def test_real_message_is_delivered(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.delenv(\"HERMES_FILTER_SILENCE_NARRATION\", raising=False)\n+    adapter = RecordingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:99887766\")\n+\n+    result = await router._deliver_to_platform(\n+        target, \"Silence is golden — here is the plan...\", metadata=None\n+    )\n+\n+    assert len(adapter.calls) == 1\n+    assert adapter.calls[0][\"content\"] == \"Silence is golden — here is the plan...\"\n+    assert result == {\"success\": True}\n+\n+\n[redacted:email]\n+async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.delenv(\"HERMES_FILTER_SILENCE_NARRATION\", raising=False)\n+    adapter = RecordingAdapter()\n+    config = GatewayConfig(filter_silence_narration=False)\n+    router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:99887766\")\n+\n+    result = await router._deliver_to_platform(target, \"*(silent)*\", metadata=None)\n+\n+    assert len(adapter.calls) == 1\n+    assert adapter.calls[0][\"content\"] == \"*(silent)*\"\n+    assert result == {\"success\": True}\n+\n+\n[redacted:email]\n+async def test_env_override_disables_filter(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.setenv(\"HERMES_FILTER_SILENCE_NARRATION\", \"0\")\n+    adapter = RecordingAdapter()\n+    # Config default is True, but env override wins.\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:99887766\")\n+\n+    result = await router._deliver_to_platform(target, \"🔇\", metadata=None)\n+\n+    assert len(adapter.calls) == 1\n+    assert result == {\"success\": True}\n+\n+\n[redacted:email]\n+async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.setenv(\"HERMES_FILTER_SILENCE_NARRATION\", \"1\")\n+    adapter = RecordingAdapter()\n+    # Config says off, env override forces on.\n+    config = GatewayConfig(filter_silence_narration=False)\n+    router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:99887766\")\n+\n+    result = await router._deliver_to_platform(target, \"*(silent)*\", metadata=None)\n+\n+    assert adapter.calls == []\n+    assert result[\"filtered\"] == \"silence_narration\"\n+\n+\n[redacted:email]\n+async def test_local_delivery_not_filtered(tmp_path, monkeypatch):\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    monkeypatch.delenv(\"HERMES_FILTER_SILENCE_NARRATION\", raising=False)\n+    router = DeliveryRouter(GatewayConfig(), adapters={})\n+\n+    results = await router.deliver(\n+        content=\"*(silent)*\",\n+        targets=[DeliveryTarget.parse(\"local\")],\n+        job_id=\"silence-job\",\n+    )\n+\n+    # Local path saved the file (no loop risk) and was not filtered.\n+    local_result = results[\"local\"]\n+    assert local_result[\"success\"] is True\n+    saved_path = local_result[\"result\"][\"path\"]\n+    assert saved_path.endswith(\".md\")\n+\n+\n+# --- Config round-trip ------------------------------------------------------\n+\n+def test_config_flag_defaults_true():\n+    assert GatewayConfig().filter_silence_narration is True\n+\n+\n+def test_config_from_dict_parses_flag():\n+    cfg = GatewayConfig.from_dict({\"filter_silence_narration\": False})\n+    assert cfg.filter_silence_narration is False\n+\n+\n+def test_config_to_dict_roundtrip():\n+    cfg = GatewayConfig(filter_silence_narration=False)\n+    assert cfg.to_dict()[\"filter_silence_narration\"] is False\n+    restored = GatewayConfig.from_dict(cfg.to_dict())\n+    assert restored.filter_silence_narration is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}