Skip to content

fix(matrix): filter phantom interrupt / redirect notices at intake - #80211

Closed
Wintle wants to merge 1 commit into
NousResearch:mainfrom
Wintle:fix/matrix-phantom-self-loop-filter
Closed

fix(matrix): filter phantom interrupt / redirect notices at intake#80211
Wintle wants to merge 1 commit into
NousResearch:mainfrom
Wintle:fix/matrix-phantom-self-loop-filter

Conversation

@Wintle

@Wintle Wintle commented Aug 6, 2026

Copy link
Copy Markdown

Summary

The matrix adapter's intake gate (_on_room_message) now drops messages
whose body matches an orchestrator-emitted phantom notice shape BEFORE any
sender / room / duplicate filtering.

Phantom shapes filtered (full-prefix match, real user messages with these
tokens mid-sentence still pass):

  • [This response was interrupted by a user correction.]
  • ↪ Redirected current run
  • ⚡ Interrupting current task.
  • ⚡ Stopped.
  • No active task to stop.
  • ♻️ Recovered reply
  • 💾 Self-improvement review:
  • 💭 Reasoning:

Why

Hermes orchestrator notices (interrupt / redirect / self-improvement
review / recovered-reply) get delivered to the gateway as ordinary
inbound messages. Without a defensive filter the matrix adapter dispatches
them, the LLM treats them as a user turn, and the gateway emits another
phantom — a self-perpetuating loop until the homeserver rate-limits the
connection. Filtering at intake is the cheapest point to break the loop.

Why this is a separate PR from #80206 (sender/reply metadata)

Different surface area, different risk profile, different rollback path.
AGENTS.md contribution rubric asks for focused PRs — these two fixes are
independently mergeable.

Verification

RED on origin/main (stashed the source fix, kept the tests):

$ git stash push -- plugins/platforms/matrix/adapter.py
$ ./venv/bin/python -m pytest tests/gateway/test_matrix_phantom_intake_filter.py
9 failed, 2 passed in 0.98s

GREEN after fix (popped the stash):

$ git stash pop
$ ./venv/bin/python -m pytest tests/gateway/test_matrix_phantom_intake_filter.py
11 passed in 0.92s

Sibling matrix tests (test_matrix*.py plus the new file):
all green.

Files changed

  • plugins/platforms/matrix/adapter.py:346 — new module-level
    PHANTOM_INTAKE_PREFIXES tuple + _is_phantom_intake_message() helper
    (full-prefix matching, rejects empty body).
  • plugins/platforms/matrix/adapter.py:3127_on_room_message now
    inspects event.content.body and returns early if the body matches a
    phantom prefix. Placed BEFORE the existing self-sender / allowed-room /
    duplicate-event gates so phantoms are dropped before any state writes.
  • tests/gateway/test_matrix_phantom_intake_filter.py (new) — 11 tests:
    parametrized coverage of every prefix shape, plus a "real message that
    happens to mention the tokens mid-sentence still passes" guard test.

Bot / duplicate check

  • Open issues referencing this: none found
  • Open PRs referencing this: none found
  • No existing branch search needed (no PR competitor)

@Wintle
Wintle force-pushed the fix/matrix-phantom-self-loop-filter branch from 5dab694 to ed937ec Compare August 6, 2026 09:19
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) labels Aug 6, 2026
@Wintle
Wintle force-pushed the fix/matrix-phantom-self-loop-filter branch 3 times, most recently from d8697a7 to 4f2f93a Compare August 6, 2026 10:29
The matrix adapter's intake gate (`_on_room_message`) now drops messages
whose body matches an orchestrator-emitted phantom notice shape BEFORE
any sender / room / duplicate filtering.

Phantom shapes filtered (full-prefix match, real user messages with these
tokens mid-sentence still pass):

- ``[This response was interrupted by a user correction.]``
- ``↪ Redirected current run``
- ``⚡ Interrupting current task.``
- ``⚡ Stopped.``
- ``No active task to stop.``
- ``♻️ Recovered reply``
- ``💾 Self-improvement review:``
- ``💭 Reasoning:``

Without an intake filter the matrix adapter dispatches these notices,
the LLM treats them as a user turn, and the gateway emits another
phantom — a self-perpetuating loop until the homeserver rate-limits
the connection. Filtering at intake is the cheapest point to break the
loop.

Verification

RED on origin/main (stashed source fix, kept tests):
    $ git stash push -- plugins/platforms/matrix/adapter.py
    $ ./venv/bin/python -m pytest tests/gateway/test_matrix_phantom_intake_filter.py
    9 failed, 2 passed in 0.98s

GREEN after fix (popped stash):
    $ git stash pop
    $ ./venv/bin/python -m pytest tests/gateway/test_matrix_phantom_intake_filter.py
    11 passed in 0.92s

Sibling matrix tests (`test_matrix*.py` plus the new file): all green.

Files changed

  - plugins/platforms/matrix/adapter.py:346 -- new module-level
    PHANTOM_INTAKE_PREFIXES tuple + _is_phantom_intake_message() helper
    (full-prefix matching, rejects empty body).
  - plugins/platforms/matrix/adapter.py:3127 -- _on_room_message now
    inspects event.content.body and returns early if the body matches a
    phantom prefix. Placed BEFORE the existing self-sender / allowed-
    room / duplicate-event gates so phantoms are dropped before any
    state writes.
  - tests/gateway/test_matrix_phantom_intake_filter.py (new) -- 11 tests:
    parametrized coverage of every prefix shape, plus regression guards
    on plain user text and on user text containing a phantom token as a
    substring.

Related

  - NousResearch#80206 (PR 1): MessageEvent gains user_id/user_name + matrix
    adapter populates them. This PR does NOT depend on NousResearch#80206 and is
    independently mergeable.
@Wintle
Wintle force-pushed the fix/matrix-phantom-self-loop-filter branch from 4f2f93a to 970793e Compare August 6, 2026 10:30
Wintle pushed a commit to Wintle/hermes-agent that referenced this pull request Aug 6, 2026
The LLM never saw a sender attribution on group/thread messages under
the default ``group_sessions_per_user=True`` config. The prefix code in
``GatewayRunner._prepare_inbound_message_text`` only fired when
``is_shared_multi_user_session(source)`` was True, and that predicate is
False by default — session keys isolate per-participant, so the LLM saw
plain text without any ``[user_name]`` prefix.

Decouple the two concerns
-------------------------

``is_shared_multi_user_session`` describes **session-key isolation**
(whether multiple participants share the same conversation history).
The sender prefix describes **message attribution** (whether the LLM
can tell who sent the current message). Those are independent questions
and conflating them was the bug.

New predicate ``_should_prefix_sender``::

    source.chat_type != "dm" and bool(source.user_name)

Prefix every non-DM inbound (group + thread), regardless of
``group_sessions_per_user`` / ``thread_sessions_per_user`` config.
DMs keep the existing 1:1 contract — no prefix.

Backward compatibility
----------------------

- ``_is_shared_multi_user`` is still computed at the top of the block
  (kept as a named local so any future consumer in the same scope that
  expects it does not break). It is no longer used for the prefix
  decision.
- DM messages: identical behaviour to before (no prefix).
- Group/thread with non-shared sessions: **NEW** — prefix now fires.
  The previous behaviour was a latent bug; if a downstream caller
  depended on the absence of a prefix on isolated group sessions it
  was depending on a bug.
- ``source.user_name`` None / empty: no prefix (same as before).
- Hostile display names containing newlines or control chars: still
  neutralized via ``neutralize_untrusted_inline_text`` before
  interpolation (same defence that already existed for shared sessions).

Tests
-----

``tests/gateway/test_sender_prefix_decoupling.py`` (new, 6 tests):

  - group message under default isolation gets prefix (the bug)
  - thread message gets prefix
  - DM message never gets prefix
  - ``user_name=None`` does not produce literal "[None]"
  - hostile name with embedded newline is neutralized
  - prefix is independent of session-isolation config

RED on origin/main: 3 failed, 3 passed
GREEN with this fix: 6 passed

Sibling matrix + session tests: all green.

Related
-------

  - NousResearch#80206 (PR 1): MessageEvent gains user_id/user_name fields.
  - NousResearch#80211 (PR 3): phantom intake filter that breaks the self-loop.
  - This PR (NousResearch#2): makes the prefix actually fire so the LLM can read
    the sender field PR NousResearch#1 added. Together these three form the
    minimal fix surface for the orphan-intake-notice issue.

Files changed
-------------

  - gateway/run.py: ``_prepare_inbound_message_text`` ~L15776 —
    replace ``if _is_shared_multi_user and source.user_name:`` with
    ``if _should_prefix_sender:`` plus block comment explaining the
    decoupling.
  - tests/gateway/test_sender_prefix_decoupling.py (new) — 6 tests,
    ~195 lines.
@Wintle Wintle closed this Aug 6, 2026
@Wintle
Wintle deleted the fix/matrix-phantom-self-loop-filter branch August 6, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants