Skip to content

fix(matrix): let the agent see the message being replied to - #51803

Closed
iainlane wants to merge 4 commits into
NousResearch:mainfrom
iainlane:fix/matrix-reply-context
Closed

iainlane wants to merge 4 commits into
NousResearch:mainfrom
iainlane:fix/matrix-reply-context

Conversation

@iainlane

@iainlane iainlane commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

When you reply to an earlier message on Matrix, the agent could see that your message was a reply but not what it replied to. So it could not use the quoted message, and answers to "what about this one?" had no idea what "this" was. Now the agent is handed the replied-to message's text and author, so it can respond in context.


When a user replies to a message on Matrix, the inbound event only carries a reference to the replied-to event via m.relates_to.m.in_reply_to.event_id. It does not include the body or sender of that earlier message. The adapter recorded the id in reply_to_message_id but never fetched the referenced event, so the agent had the pointer without the content and could not tell what the user was replying to.

Fetch the replied-to event from the homeserver with the client's get_event API and populate the reply_to_text, reply_to_author_id, reply_to_author_name and reply_to_is_own_message fields. On the gateway side, run.py previously injected only the quoted text; it now reads the author fields too and names the author in the per-turn reply prefix, preferring the display name and falling back to the platform id, so the agent sees [Replying to Bob: "..."]. Replies to the bot's own message keep the existing "your previous message" wording, and events without author fields keep the plain prefix, so other platforms are unaffected.

Threaded messages whose m.in_reply_to is a thread fallback (is_falling_back) are thread linkage rather than a genuine reply, so they are skipped to avoid noisy context. The fetch is best-effort and bounded by a 10 second timeout: a slow, failed or forbidden lookup degrades to no context and never blocks handling of the new message or holds up the sync loop.

The reply-fallback stripping that was inline in the text handler is lifted into a shared helper and now also applies to the fetched body. The media handler, which previously dropped reply metadata entirely, carries the same context.

@iainlane iainlane changed the title fix(matrix): surface replied-to message content to the agent fix(matrix): let the agent see the message being replied to Jun 24, 2026
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/matrix Matrix adapter (E2EE) P2 Medium — degraded but workaround exists labels Jun 24, 2026
@iainlane
iainlane marked this pull request as ready for review June 24, 2026 17:26
@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

Additional source-backed validation for this direction (no private event content):

I checked this against the Matrix Client-Server spec and related Hermes issues/PRs. Matrix rich replies are identified by content.m.relates_to.m.in_reply_to.event_id; the quoted body / <mx-reply> fallback is compatibility material that clients may strip, so it should not be the only source of reply context. The threading section also uses is_falling_back=true as a thread fallback marker, which means a robust implementation should avoid treating thread fallback relations as ordinary direct replies.

That matches #18396 and the surrounding implementation attempts (#18680 cache, #28962 fallback parsing + parent fetch, #39611 API fetch). In practice, the important acceptance boundary is:

  • if Matrix provides a parent event id, the agent should receive resolved parent text/author when safely available;
  • if the parent cannot be resolved, the agent should still receive an explicit unresolved-reply marker rather than silently guessing from nearby conversation context;
  • E2EE / clients without inline fallback should not degrade into “reply id only, no prompt context”;
  • is_falling_back thread fallbacks should not be injected as normal direct replies.

This PR looks aligned with the durable fix because it moves the actual replied-to message text/author into the agent-facing event instead of relying on stripped fallback text. Happy to test direct-reply plus digest/cron-reply scenarios if useful.

Sources checked: Matrix rich replies and threading in the Client-Server spec — https://spec.matrix.org/latest/client-server-api/#rich-replies and https://spec.matrix.org/latest/client-server-api/#threading.

@PatrickHuetter

Copy link
Copy Markdown

+1 for that!

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a verified Matrix reply-context gap. Current main records the parent event id but no parent text (plugins/platforms/matrix/adapter.py:2736-2774), while gateway injection requires reply_to_text (gateway/run.py:10592-10606).

Problems

  • The new reply_to_author_id / reply_to_author_name fields are not injected into the agent-facing prefix: gateway/run.py:10592-10606 reads only reply text and the own-message flag. The claimed author context is therefore not delivered.
  • await get_event(...) at proposed plugins/platforms/matrix/adapter.py:3588 has no timeout. The current sync loop awaits dispatch (adapter.py:2313), and dispatch awaits its handler tasks (adapter.py:2359), so a stuck lookup can delay sync processing.

Suggested changes

  • Render the resolved author in the per-turn reply prefix and add an assertion against the constructed agent input.
  • Bound get_event with asyncio.wait_for, degrade to empty context on timeout, and test a non-completing lookup.

Automated hermes-sweeper review.

message_id=event_id,
reply_to_message_id=reply_to,
reply_to_text=reply_ctx.text,
reply_to_author_id=reply_ctx.author_id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gateway/run.py:10592-10606 never reads reply_to_author_id or reply_to_author_name; it injects only reply text and the own-message flag. Please include the author in the per-turn reply prefix (with an integration assertion), or remove these fields and narrow the claimed behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f727d73e8: the gateway now reads these fields and names the author in the per-turn prefix, preferring reply_to_author_name and falling back to reply_to_author_id, producing [Replying to Bob: "..."]. Own-message replies keep the "your previous message" wording, and events without author fields keep the old prefix, so other platforms are unaffected (Signal, which also sets these fields, picks up the author for free). The integration assertion is test_reply_author_reaches_agent_prefix in 808cfd136: it dispatches a Matrix reply through the adapter and asserts the author the adapter resolved appears in the prefix built by _prepare_inbound_message_text.

Comment thread plugins/platforms/matrix/adapter.py Outdated
return _EMPTY_REPLY_CONTEXT

try:
event = await get_event(RoomID(room_id), EventID(reply_to_event_id))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please bound this lookup with asyncio.wait_for and degrade on TimeoutError. Current main awaits handler tasks in _dispatch_sync (adapter.py:2359), so a hung homeserver request delays sync-cycle completion despite the intended best-effort behavior; add a never-completing get_event regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 808cfd136: the lookup is now wrapped in asyncio.wait_for with a 10 second bound (_reply_context_timeout_seconds). On timeout it logs at debug, matching the existing fetch-failure path, and returns the empty context so the message is handled without reply context and the sync cycle is not held up. The regression test is test_hung_get_event_times_out_and_degrades, which drives a never-completing get_event through the text handler and asserts the message still reaches handle_message with the context fields unset.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@iainlane
iainlane requested a review from teknium1 July 15, 2026 08:52
@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

Additional local integration and adversarial validation against current main (07e97d2f) found two trust-boundary gaps in the fetched Matrix parent context.

  1. _resolve_reply_context() fetches the parent event and copies its sender/text without consulting the adapter's registered _is_sender_authorized() path. In an isolated test, the authorization callback returned False, but it was never called and the disallowed parent's text and identity were still returned as ordinary reply context.
  2. gateway/run.py interpolates both the fetched display name and parent text directly into the agent-facing [Replying to ...] prefix. Embedded newlines and framing-like Markdown remain intact. Current main already uses _is_sender_authorized(), [unverified], and neutralize_untrusted_inline_text() for comparable externally fetched Slack/Discord context.

The PR's focused reply/injection tests passed after a clean local rebase (21 passed). Two additional behavioral audits confirmed the authorization bypass and raw multi-line interpolation.

Before merge, I recommend classifying the parent sender through _is_sender_authorized(), either dropping unauthorized parent content or marking it explicitly as unverified/background context, and neutralizing the fetched display name and text before constructing the reply prefix. Regressions should cover an unauthorized participant in an allowed room plus multi-line/framing-like parent fields.

x7peeps added a commit to x7peeps/hermes-agent that referenced this pull request Jul 26, 2026
…#51803 followup)

Addresses two trust-boundary gaps found during review of PR NousResearch#51803:

1. Matrix adapter _resolve_reply_context() now checks
   _matches_ignored_user_pattern() before surfacing replied-to sender
   identity or text. A reply referencing a message from a blocked/ignored
   sender will no longer leak their content to the agent.

2. gateway/run.py _prepare_inbound_message_text() now neutralizes
   reply_to_text and reply_to_author_name via neutralize_untrusted_inline_text()
   to collapse embedded newlines. Prevents prompt injection via
   fake headings or override blocks in the [Replying to ...] prefix.

Regression tests added:
- test_ignored_sender_reply_context_suppressed (test_matrix.py)
- test_reply_text_neutralized_collapses_newlines (test_reply_to_injection.py)
- test_reply_author_neutralized_collapses_newlines (test_reply_to_injection.py)
- test_reply_snippet_truncated_to_neutralize_limit (updated)
When a user replies to a message on Matrix, the inbound event only
carries a reference to the replied-to event via
`m.relates_to.m.in_reply_to.event_id`. It does not include the body or
sender of that earlier message. The adapter recorded the id in
`reply_to_message_id` but never fetched the referenced event, so the
agent had the pointer without the content and could not tell what the
user was replying to.

Fetch the replied-to event from the homeserver with the client's
`get_event` API and populate the `reply_to_text`, `reply_to_author_id`,
`reply_to_author_name` and `reply_to_is_own_message` fields that
`run.py` already injects as a quoted-context prefix.

Threaded messages whose `m.in_reply_to` is a thread fallback
(`is_falling_back`) are thread linkage rather than a genuine reply, so
they are skipped to avoid noisy context. The fetch is best-effort: a
failed or forbidden lookup degrades to no context and never blocks
handling of the new message.

The reply-fallback stripping that was inline in the text handler is
lifted into a shared helper and now also applies to the fetched body.
The media handler, which previously dropped reply metadata entirely,
carries the same context.
MessageEvent carries reply_to_author_id and reply_to_author_name (set by
the Signal and Matrix adapters), but _prepare_inbound_message_text never
read them: the per-turn prefix said only [Replying to: "..."], so the
agent knew which text was referenced but not whose message it was.

Include the author in the prefix, preferring the display name and
falling back to the platform id, producing [Replying to Bob: "..."].
Replies to the bot's own message keep the existing 'your previous
message' wording. Events without author fields keep the old prefix
unchanged.
_resolve_reply_context awaits client.get_event with no bound, and the
sync loop awaits message handlers in _dispatch_sync, so a homeserver
that accepts the request but never answers would stall sync-cycle
completion on what is meant to be a best-effort context fetch.

Wrap the lookup in asyncio.wait_for with a 10 second bound. On timeout,
log at debug (matching the existing fetch-failure path) and return the
empty context so the message is handled without reply context. A
regression test drives a never-completing get_event through the text
handler and asserts the message still goes out with the context fields
unset. Also add an integration test asserting the author resolved by
the adapter reaches the gateway's per-turn reply prefix.
@iainlane
iainlane force-pushed the fix/matrix-reply-context branch from 808cfd1 to 96f8abf Compare August 3, 2026 22:14
Resolving a Matrix reply fetches the parent event from the homeserver, so
its body and display name are content from another participant that the
adapter now pulls in itself. Two trust boundaries were missing on that
path.

The parent's sender was never classified. The adapter has an
authorization check registered by GatewayRunner, but _resolve_reply_context
copied the sender and body without consulting it, so a reply to someone
off the allowlist reached the agent as ordinary context. Classify the
sender through _is_sender_authorized() and carry the tri-state result on
the event, so the gateway can label off-list content [unverified] the way
the Slack and Discord thread-context paths already do. Our own messages
skip the check: the allowlist governs who may drive the agent, not what
the agent said.

The reply prefix also interpolated both the display name and the quoted
body raw. Both are attacker-controlled and the prefix is prepended
verbatim to the model turn, so an embedded newline let either break out of
the bracketed line and pose as a fresh markdown section. Run both through
neutralize_untrusted_inline_text() before building the prefix; the quote
keeps its existing 500-char bound rather than the helper's default.
@iainlane

iainlane commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both trust-boundary findings are addressed in bbb23eeac, and the branch is rebased onto current main.

_resolve_reply_context() fetches the parent event and copies its sender/text without consulting the adapter's registered _is_sender_authorized() path.

Confirmed, and the reproduction was accurate: the callback is registered for every adapter by GatewayRunner, but the Matrix adapter never called it. _resolve_reply_context() now takes the chat_type already in scope at both call sites and classifies the parent's sender through _is_sender_authorized(sender, chat_type=..., chat_id=room_id). The tri-state result rides on a new MessageEvent.reply_to_author_authorized, and the gateway labels off-list content [unverified], matching what the Slack and Discord thread-context paths already do rather than dropping the content outright. The bot's own messages skip the check, since the allowlist governs who may drive the agent, not what the agent said.

gateway/run.py interpolates both the fetched display name and parent text directly into the agent-facing [Replying to ...] prefix.

Also confirmed. Both now go through neutralize_untrusted_inline_text() before the prefix is built. The quote is neutralised with max_chars=0 so the existing 500-character bound stays the cap rather than being silently tightened to the helper's 240 default.

Worth flagging for a maintainer: this prefix was unneutralised on main before this PR too, and eleven platforms populate reply_to_text. Fixing it at the gateway/run.py chokepoint covers all of them, so the hardening here is not Matrix-specific even though the authorization half is.

Regressions cover both scenarios you named: an unauthorized participant in an allowed room (asserting the [unverified] label reaches the constructed agent input, plus the authorized and own-message cases), and multi-line parent fields carrying a ## SYSTEM heading in both the body and the display name, asserting no line of the resulting turn starts with the injected heading. Three of the four fail without the fix.

iainlane added a commit to iainlane/hermes-agent that referenced this pull request Aug 15, 2026
Name the replied-to author in the [Replying to ...] prefix, preferring
the display name and falling back to the platform id, so the agent
knows whose message is being referenced rather than only its text.

Both the quoted snippet and the author name come from another
participant and are prepended raw to the model turn, so collapse them
to a single inert line with neutralize_untrusted_inline_text. An
embedded newline would otherwise let either pose as a fresh markdown
section (a fake "## SYSTEM" heading). This overlaps with the snippet
neutralisation in flight as PR NousResearch#65184; the call shape is the same.

Add a tri-state reply_to_author_authorized field to MessageEvent,
mirroring _is_sender_authorized (None means no check registered). The
Matrix adapter classifies the parent's sender through the registered
allowlist check on the fetch path only: fetched content from someone
off the allowlist is tagged "[unverified] " in the prefix so the agent
treats it as background rather than instructions. Inline fallback
quotes carry no verdict, and the bot's own messages are never checked;
the allowlist governs who may drive the agent, not what it said. The
requeued-event copy in _busy_queue_command carries the field too.

_resolve_reply_context now returns a frozen _MatrixReplyContext
dataclass instead of a widening tuple.

Ported from NousResearch#51803 (@iainlane), retargeted onto the spec-correct
threading model of this branch.
@iainlane

Copy link
Copy Markdown
Contributor Author

Retiring this PR in favour of #62088, which now carries everything here: the adapter-side reply resolution is subsumed by its typed-relation model, and the hardening this PR added (author naming, tri-state reply_to_author_authorized with [unverified] labelling, and neutralisation of the reply prefix) has been ported there with tests and credit. The prefix neutralisation itself also exists independently as #65184, which predates both.

@iainlane iainlane closed this Aug 15, 2026
@PatrickHuetter

Copy link
Copy Markdown

@iainlane why did you close the PR?

@iainlane

Copy link
Copy Markdown
Contributor Author

@iainlane why did you close the PR?

The fix is now a part of #62088 & it was getting confusing merging lots of stacked PRs all of the time, so I rolled it up

@PatrickHuetter

Copy link
Copy Markdown

@iainlane Thanks for your good work on this topic! Hopefully #62088 will get merged soon.

iainlane added a commit to iainlane/hermes-agent that referenced this pull request Aug 23, 2026
Name the replied-to author in the [Replying to ...] prefix, preferring
the display name and falling back to the platform id, so the agent
knows whose message is being referenced rather than only its text.

Both the quoted snippet and the author name come from another
participant and are prepended raw to the model turn, so collapse them
to a single inert line with neutralize_untrusted_inline_text. An
embedded newline would otherwise let either pose as a fresh markdown
section (a fake "## SYSTEM" heading). This overlaps with the snippet
neutralisation in flight as PR NousResearch#65184; the call shape is the same.

Add a tri-state reply_to_author_authorized field to MessageEvent,
mirroring _is_sender_authorized (None means no check registered). The
Matrix adapter classifies the parent's sender through the registered
allowlist check on the fetch path only: fetched content from someone
off the allowlist is tagged "[unverified] " in the prefix so the agent
treats it as background rather than instructions. Inline fallback
quotes carry no verdict, and the bot's own messages are never checked;
the allowlist governs who may drive the agent, not what it said. The
requeued-event copy in _busy_queue_command carries the field too.

_resolve_reply_context now returns a frozen _MatrixReplyContext
dataclass instead of a widening tuple.

Ported from NousResearch#51803 (@iainlane), retargeted onto the spec-correct
threading model of this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/matrix Matrix adapter (E2EE) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants