fix(matrix): filter phantom interrupt / redirect notices at intake - #80211
Closed
Wintle wants to merge 1 commit into
Closed
fix(matrix): filter phantom interrupt / redirect notices at intake#80211Wintle wants to merge 1 commit into
Wintle wants to merge 1 commit into
Conversation
Wintle
force-pushed
the
fix/matrix-phantom-self-loop-filter
branch
from
August 6, 2026 09:19
5dab694 to
ed937ec
Compare
Wintle
force-pushed
the
fix/matrix-phantom-self-loop-filter
branch
3 times, most recently
from
August 6, 2026 10:29
d8697a7 to
4f2f93a
Compare
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
force-pushed
the
fix/matrix-phantom-self-loop-filter
branch
from
August 6, 2026 10:30
4f2f93a to
970793e
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The matrix adapter's intake gate (
_on_room_message) now drops messageswhose 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)
MessageEventnow carriesuser_id/user_name/reply_to_*so the LLM CAN read sender info.the LLM.
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):
GREEN after fix (popped the stash):
Sibling matrix tests (
test_matrix*.pyplus the new file):all green.
Files changed
plugins/platforms/matrix/adapter.py:346— new module-levelPHANTOM_INTAKE_PREFIXEStuple +_is_phantom_intake_message()helper(full-prefix matching, rejects empty body).
plugins/platforms/matrix/adapter.py:3127—_on_room_messagenowinspects
event.content.bodyand returns early if the body matches aphantom 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