hermes - 💡(How to fix) Fix gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

Add a pre-send filter in gateway/delivery.py::_deliver_to_platform (around line 342, immediately before await adapter.send(...)). If the finalized content matches the silence-narration regex, drop the message silently (log it, do not deliver, do not raise) and return a "filtered" result so callers don't treat it as a delivery error.

Fix Action

Fix / Workaround

  • Single chokepoint — every platform adapter (Discord, Telegram, Slack, Matrix, WhatsApp, Feishu, etc.) flows through _deliver_to_platform. One filter, fleet-wide coverage.

  • After agent loop — model-side prompt rules can fail; this catches the failure regardless of which persona's SOUL.md/memory drifted.

  • Before adapter dispatch — never hits the Discord API, never delivered, never seen by the recipient bot, so the loop is broken at the substrate layer.

  • Not in agent loop — would have to be duplicated across every model provider adapter. Wrong layer.

  • Discovered in Clarami Avengers fleet during the #220 PROJECT_SUMMARY ship cycle (2026-05-29)

  • Behavioral mitigation (SOUL.md standing order, memory entry) was already in place when this loop happened — the model emitted the banned string anyway

  • Originating reporter / co-designer: Captain America persona (Hermes agent, NousResearch internal fleet)

  • Hermes config repo for the Clarami fleet has the standing order documented under ~/AppData/Local/hermes/profiles/*/SOUL.md — those entries stay (defense in depth), this issue adds the substrate guard.

Code Example

import re

# Matches strings that are *only* a "silence" narration with optional markdown wrappers.
# Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent, 🔇, .,,
# combinations with whitespace, and the bare narration tokens we've seen in the wild.
_SILENCE_NARRATION = re.compile(
    r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
    r'|^[\s*_~`]*[🔇\.…]+[\s*_~`]*$',
    re.IGNORECASE,
)

def _is_silence_narration(content: str) -> bool:
    if not content or len(content) > 64:  # length guard — real messages are longer
        return False
    return bool(_SILENCE_NARRATION.match(content.strip()))

---

# ~line 342, just before adapter.send(...)
if _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}
result = await adapter.send(target.chat_id, content, metadata=send_metadata or None)
RAW_BUFFERClick to expand / collapse

Problem

When a model has nothing actionable to say in response to a tool result or another bot's ack, it sometimes hallucinates a "silence" message — *(silent)*, *Silence.*, 🔇, a bare ., or similar — instead of emitting zero tokens. In bot-to-bot scenarios (Hermes agents talking to each other on Discord/Telegram/Slack), the receiving bot then mirrors that "silence" back, creating a tight 2-N hop loop that burns API tokens and (observed) can crash one of the models entirely ("Model returned no content after all retries").

Reproduction (observed 2026-05-29 in Clarami Avengers fleet)

  1. Two Hermes bot personas (Tony, Cap) in the same Discord guild with cross-bot mentions enabled
  2. Tony posts a [RESULT]-style status update to Cap's channel
  3. Cap's SOUL.md says "no acknowledgment for [RESULT] traffic"
  4. Cap's model emits *(silent)* instead of producing zero output
  5. Tony's SOUL.md has the same rule — also emits *(silent)* in response
  6. Loop continues until one model crashes with "no content after all retries"

Why behavioral rules aren't enough

SOUL.md prompts and memory entries can say "never emit silence narration" but those instructions:

  • Don't survive model drift across providers/versions
  • Are inconsistently honored under high context pressure
  • Require updating every persona individually (no single chokepoint)
  • Have already failed in practice (the standing order was in place when this loop happened)

A substrate-level filter on the outbound gateway path catches the failure regardless of which persona's prompt drifted.

Proposed solution

Add a pre-send filter in gateway/delivery.py::_deliver_to_platform (around line 342, immediately before await adapter.send(...)). If the finalized content matches the silence-narration regex, drop the message silently (log it, do not deliver, do not raise) and return a "filtered" result so callers don't treat it as a delivery error.

Regex

import re

# Matches strings that are *only* a "silence" narration with optional markdown wrappers.
# Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent, 🔇, ., …,
# combinations with whitespace, and the bare narration tokens we've seen in the wild.
_SILENCE_NARRATION = re.compile(
    r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
    r'|^[\s*_~`]*[🔇\.…]+[\s*_~`]*$',
    re.IGNORECASE,
)

def _is_silence_narration(content: str) -> bool:
    if not content or len(content) > 64:  # length guard — real messages are longer
        return False
    return bool(_SILENCE_NARRATION.match(content.strip()))

Wiring (gateway/delivery.py)

# ~line 342, just before adapter.send(...)
if _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}
result = await adapter.send(target.chat_id, content, metadata=send_metadata or None)

Why this location

  • Single chokepoint — every platform adapter (Discord, Telegram, Slack, Matrix, WhatsApp, Feishu, etc.) flows through _deliver_to_platform. One filter, fleet-wide coverage.
  • After agent loop — model-side prompt rules can fail; this catches the failure regardless of which persona's SOUL.md/memory drifted.
  • Before adapter dispatch — never hits the Discord API, never delivered, never seen by the recipient bot, so the loop is broken at the substrate layer.
  • Not in agent loop — would have to be duplicated across every model provider adapter. Wrong layer.

Acceptance criteria

  • Outbound message of *(silent)* to Discord/Telegram/Slack is dropped pre-send, with a WARNING log entry containing platform, chat_id, and truncated content
  • delivery.py returns {"success": True, "filtered": "silence_narration", "delivered": False} instead of raising or calling the adapter
  • A real message containing the word "silent" in normal context (e.g., "The deployment ran silently in the background") is not dropped — the regex anchors to start/end and has a 64-char length guard
  • Unit tests cover at minimum: *(silent)*, *Silence.*, 🔇, ., , (silent), _silent_, silent, *(silent)*, plus negative cases (Silence is golden — here is the plan..., Silent install completed, normal multi-line responses)
  • Cron jobs / local delivery target are not affected (_deliver_local is a separate code path — local-saved silence messages have no loop risk)
  • Filter is opt-out-able via a config flag (gateway.filter_silence_narration: true default) for users who want raw passthrough

Out of scope

  • Filtering substantive low-content messages (e.g. ok, 👍) — those are legitimate acks in some contexts, only true narration tokens are in scope
  • Filtering inbound — only outbound matters for loop prevention
  • Per-platform overrides — Discord, Telegram, et al. all benefit equally
  • Migrating existing SOUL.md/memory rules — keep them as belt-and-suspenders, the filter is the suspenders

Test plan

  • Unit: tests/gateway/test_delivery_silence_filter.py_is_silence_narration truth table, ~15 positive + ~10 negative cases
  • Integration: mock adapter, call Deliverer.deliver(content="*(silent)*", ...), assert adapter.send was not called and result has filtered: "silence_narration"
  • Manual: in a two-bot Discord setup, force one persona to emit *(silent)*; confirm it never appears in channel and the receiving bot does not respond

Related context

  • Discovered in Clarami Avengers fleet during the #220 PROJECT_SUMMARY ship cycle (2026-05-29)
  • Behavioral mitigation (SOUL.md standing order, memory entry) was already in place when this loop happened — the model emitted the banned string anyway
  • Originating reporter / co-designer: Captain America persona (Hermes agent, NousResearch internal fleet)
  • Hermes config repo for the Clarami fleet has the standing order documented under ~/AppData/Local/hermes/profiles/*/SOUL.md — those entries stay (defense in depth), this issue adds the substrate guard.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix gateway: drop outbound 'silence narration' messages pre-send (anti-loop control)