Skip to content

fix(matrix): keep the whole thread root for a new thread session - #137687

Draft
HarshitRuwali wants to merge 86 commits into
NousResearch:mainfrom
HarshitRuwali:fix/matrix-thread-root-full-text
Draft

HarshitRuwali wants to merge 86 commits into
NousResearch:mainfrom
HarshitRuwali:fix/matrix-thread-root-full-text

Conversation

@HarshitRuwali

@HarshitRuwali HarshitRuwali commented Oct 13, 2026 •

Copy link
Copy Markdown

Stack

The same change is open against #125697's branch as iainlane#11, ready to merge there. This PR is the main-targeting layer of the stack and stays a draft until #125697 lands. Like the rest of the stack, it targets main and includes its prerequisites. Those are #125648 and #125697 rebased onto current main; the one conflict, the environment allowlist in scripts/run_tests.sh, keeps the variables from both sides. The change itself is ba02c81131. The top commit, 17390b6bd9, applies ruff rules that main enabled after the stack was written (UP006, UP037, UP041, PLW1510, RUF024) to the stack's lines, so ruff check . passes again. Those fixes are mechanical and change no behaviour.

What does this PR do?

Closes two gaps in #125697's history for the first turn of a new thread session. Both came up on a real deployment (#137686): in an E2EE DM, the bot answered in the main timeline with a 2,671-character message whose options began at character 1,912, and the user replied in a thread on it, "go with option A".

  1. The root was cut to 1,200 characters. _format_history_context passes max_chars=1200 for every entry, the root included, so the options never reached the model and the thread still had no usable context. The root is now marked (MatrixEventContext.is_thread_root) and kept whole; every other entry keeps the cap. This matches the reply path, which keeps the replied-to text whole because "a preview here silently loses later list items" (gateway/run_inbound.py:1603), and Slack's thread context, which is untruncated (plugins/platforms/slack/adapter.py:5958).
  2. A failed thread page dropped the root. fetch_thread_entries returned [] when the /context or /relations request failed, before the root lookup. A new thread session now still gets the root, which is a single event. Catch-up keeps returning [], because it includes the root only when the scan found no previous turn, which a failed page cannot show.

Size: the root is still one event. A bot root is at most max_message_length (15,000 bytes by default); another sender's root is bounded by the Matrix event size limit. Newline neutralization and the [unverified] tag still apply to it.

Related Issue

Refs #137686. With #125697 this covers that report's reproduction; the smaller points listed there (an edited root read in its first version after a restart, no notice to the model when the root cannot be decrypted, and the main-timeline exchange before the root) are not addressed here.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • plugins/platforms/matrix/reply_context.py: MatrixEventContext.is_thread_root, default False, last field, so positional construction is unchanged.
  • plugins/platforms/matrix/thread_context.py: the root entry is appended as dataclasses.replace(root, is_thread_root=True), so the cached entry is not changed. On a paging failure a new thread session falls through to the root lookup; catch-up still returns [].
  • plugins/platforms/matrix/adapter.py: _format_history_context uses max_chars=0 for the root. It is a same-line edit, so the file does not grow.
  • tests/gateway/test_matrix_thread_root_context.py (new): five behaviour tests.
    • A DM with a bot-written root longer than 1,200 characters arrives whole.
    • Other thread entries stay capped.
    • The root survives a failed thread page.
    • Catch-up skips the root on a failed page, even when the root is readable.
    • A catch-up root from an unverified sender stays whole, on one line, with its [unverified] tag.
  • tests/gateway/test_matrix.py: four equality assertions now expect the root with is_thread_root=True, edited in place.
  • website/docs/user-guide/messaging/matrix.md: one sentence. The root is included in full even when earlier messages cannot be fetched, and earlier messages over 1,200 characters are shortened.

How to Test

  1. Run scripts/run_tests.sh tests/gateway/test_matrix_thread_root_context.py tests/gateway/test_matrix.py.
  2. To reproduce:
    • In a DM with the bot, get an answer longer than 1,200 characters whose end matters, such as options listed last.
    • Reply to it in a thread.
    • On feat(matrix): backfill recent room and thread context #125697 alone, the root line in [Earlier messages in this thread] ends in ... without the options. With this PR, the whole answer is there.

What I ran after rebasing onto main at 0438ca2da5 (Linux container, Python 3.14, mautrix 0.21.1):

  • Build and lint:
    • py_compile on every changed Python file: passes.
    • bash -n scripts/run_tests.sh: passes.
    • ruff check . over the whole repo: passes.
  • Tests: all 18 test files the stack touches pass, as do every tests/gateway/test_matrix*.py file, run one file per process:
    • tests/gateway/test_matrix.py: 239 passed.
    • The new file: 5 passed.
    • tests/agent/test_context_compressor.py: 305 passed.
    • tests/gateway/test_session.py: 67 passed.
    • tests/scripts/test_run_tests_shell.py: 2 passed.
    • tests/ci/test_classify_changes.py and tests/ci/test_required_results.py, which cover main's side of the run_tests.sh conflict: 159 and 90 passed.
    • The one exception is tests/gateway/test_matrix_deps_rebind.py (2 failed). It fails the same way on unmodified main in my lighter environment.
  • Code health:
  • New tests (before the rebase): each fails when its code change is reverted.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate (this builds on feat(matrix): backfill recent room and thread context #125697; fix(matrix): fetch thread root content for agent context #132105 overlaps but reads the root without decrypting)
  • My PR contains only changes related to this fix/feature (no unrelated commits), apart from the stack prerequisites above
  • I've run pytest tests/ -q and all tests pass. Not the whole suite: I ran the Matrix-related files listed above.
  • python scripts/check passes. Ruff passes on the whole repo, and this PR's own commits have no code-health findings. The stack below it has 27 pre-existing code-health findings against main; see above.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Debian (container) for the tests; the bug itself on Kali Linux

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings)
  • I've updated cli-config.yaml.example if I added/changed config keys: N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows: N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide: N/A, no file I/O, process or terminal changes
  • I've updated tool descriptions/schemas if I changed tool behavior: N/A

Not changed here, but worth offering: the broad catch in fetch_thread_entries now continues instead of returning, so adding exc_info=True to its logger.debug call would keep the traceback. It is #125697's line, so I left it for you.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) area/config Config system, migrations, profiles area/docker area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation labels Oct 13, 2026
Matrix changes lacked a live exchange test, so mocked adapter tests
could miss failures between Synapse, gateway setup, and model-visible
context.

Start disposable Synapse and a checkout-built Linux gateway through
Testcontainers. Drive events with a separate matrix-nio account and a
fake model under an isolated HERMES_HOME. Provision a Linux nio client
with its own encrypted device store so later encrypted cases can also
run from macOS hosts.

Run the suite in a dedicated CI job through scripts/run_tests.sh. The
plain-room test confirms both the reply and the inbound model context.
Synapse changes the owner of /data to UID 991 when it generates its
configuration. On a Linux runner, the host test process cannot append
to the resulting homeserver.yaml. Store Synapse data in a Docker volume
and edit the configuration from a one-shot container.

The test runner can kill a timed-out file worker before pytest tears
down its fixtures. Enable Testcontainers' Ryuk reaper, pin its image by
digest, and start it before creating the volume. Label the Synapse
volume with the session ID; Testcontainers labels its network and
containers. Ryuk can then remove these resources when the worker exits.
Parallel Matrix live tests built different checkouts under the same
hermes-matrix-live:checkout tag. A second build could replace the tag
between the first build and its container start, so a green test might
exercise the wrong code.

Give each fixture invocation a UUID image tag and remove that exact tag
after its containers stop. Concurrent branches can now run the live
suite without changing which checkout an existing fixture starts.
A file timeout kills the pytest worker and its build process without
running fixture teardown. The unique image tag from the live test can
then remain on the Docker host indefinitely.

Start Ryuk before building and label the checkout image with the same
Testcontainers session ID as its containers, network and volume. Ryuk
can remove the image after the worker dies. A SIGKILL probe confirmed
that the pinned Ryuk image removes a labelled Docker image.
Matrix live tests built the production image for each checkout. That
build prepares SQLite, Chromium, Node and the UI even though the tests
start only a gateway. The separate Linux observer also built a second
Python environment at test setup time to obtain matrix-nio crypto.

Build a small Debian image with the PM-pinned Python and the locked
Matrix and test dependencies before copying the checkout source. The
gateway and observer use the same prepared interpreter in separate
containers, while the gateway still runs against an isolated home and
the host fake model. Keep gateway logs in timeout failures so startup
problems remain diagnosable.

The plain-room test passed with a 1.545-second test body and 26.8
seconds for the complete test file. On this host, an uncached image
build took 128.1 seconds and a cached rebuild took 4.67 seconds.
The live plain-room test requested a one-second Synapse poll and
allowed a minute before reporting a missing reply. Matrix-nio can
retry a sync request beyond that loop deadline, so a stalled
exchange could take even longer to fail.

Request a 250 ms server wait and bound each sync by the remaining
15-second deadline. Successful replies still arrive through the
real Matrix event stream. The separate-client Synapse test passed.
The approval client sends cards, seed reactions and requester controls in
quick succession. Synapse's default account event limit exhausts its
burst and rejects the controls before the short client deadline.

Configure the disposable server for 100 events per second with a burst
of 100. The limit remains finite, and tests can exercise protocol events
without waiting for the production rate limiter to refill. Client waits,
retry rules and assertions are unchanged.
The Matrix live fixtures skipped with "require a running Docker
daemon" on macOS installations whose daemon is reachable only through
a Docker CLI context, such as Docker Desktop or Colima without the
/var/run/docker.sock symlink. The fixture replaced DOCKER_CONFIG with
an empty configuration to keep registry credentials away from the
tests. That also discarded currentContext and the context metadata, so
the Docker SDK, Testcontainers and the docker CLI all fell back to
/var/run/docker.sock. scripts/run_tests.sh removed DOCKER_HOST,
DOCKER_CONTEXT and DOCKER_CONFIG from the test environment, so a user
could not select the socket explicitly either.

The fixture now resolves the endpoint the same way the docker CLI
does (DOCKER_HOST, then DOCKER_CONTEXT, then currentContext) before it
isolates the configuration. It exports the result as DOCKER_HOST and
removes DOCKER_CONTEXT, so the SDK probe, Testcontainers and the
buildx build all connect to that endpoint. The isolated configuration
keeps the CLI plugin directories, because Docker Desktop and Homebrew
install buildx outside the default search path. It still contains no
registry credentials or credential helpers.

On macOS the daemon runs in a Linux VM and resolves bind-mount sources
there. Testcontainers mounts the client-side socket path into Ryuk by
default, which fails when that path is a host socket such as a
context's. The fixture therefore mounts /var/run/docker.sock, the
socket inside the VM, into Ryuk on macOS.

The runner forwards DOCKER_HOST, DOCKER_CONTEXT and DOCKER_CONFIG. It
still drops DOCKER_AUTH_CONFIG, DOCKER_CERT_PATH and DOCKER_TLS_VERIFY.

With a scratch context whose socket forwards to OrbStack and counts
connections, the plain exchange passed with 70 API connections through
that socket. The previous fixture passed the same invocation with no
connections through it, because it used /var/run/docker.sock instead.
A context pointing at a missing socket now skips with a message that
includes the socket path.
The last dependency step of the test Dockerfile imports olm,
mautrix.crypto and nio, so an image without Matrix crypto support
already fails to build, and the fixture reports the build log. The
fixture then started a container from the finished image only to run
the same imports again. That check could not fail, and it added a
container start to every test module.
The gateway_image fixture built the Linux gateway image inside the
pytest worker. run_tests_parallel.py kills a worker when the per-file
timeout expires, so the cold build, the Synapse start-up and the
exchange all had to fit in the 300-second per-file timeout that the
workflow sets. The 45-minute job limit and the build's own
1800-second timeout could never take effect.
A cold build of test_plain_exchange.py on OrbStack took 242 seconds of
that budget.

Move the build command to tests/integration/matrix_live/image_build.py.
The fixture still uses it, so a local run builds a fresh image and
removes it afterwards as before. The workflow runs the same module in
a step before the tests and passes the tag in
HERMES_TEST_MATRIX_IMAGE. When that variable is set, the fixture uses
the image, checks that the daemon has it, and leaves it in place for
the next test file. run_tests.sh forwards the variable, in the same way
that it forwards HERMES_TEST_IMAGE for tests/docker. The build no
longer has its own timeout: in the fixture the per-file timeout limits
it, and in CI the job limit does.

Drop the Buildx setup step. The fixture replaces DOCKER_CONFIG, where
Buildx records the builder that the step creates, so every build used
the daemon's default builder anyway.

The pull_request path filter listed the production Dockerfile, which
the live tests no longer use, and omitted most of the code that runs
in the gateway. Filter on the code that the image copies and runs
(the root modules, agent, cron, gateway, hermes_cli, hermes_platform,
pm and tools), .dockerignore, the test runner and its setup action.

With a prebuilt image, test_plain_exchange.py passed in 70 seconds.
The gateway container ran as UID 10000 and wrote into a bind-mounted
home that the fixture made world-writable. With rootful Docker on
Linux, the directories that the gateway creates, such as logs/, belong
to UID 10000 with mode 0755, so the runner's user cannot delete the
files in them. run_tests_parallel.py ignores errors when it removes its
temporary root, so each test left a tree under
/var/tmp/hermes-pytest-<uid>/ that only root could remove. macOS was
not affected, because OrbStack and Docker Desktop map bind-mount
ownership to the host user.

Run the gateway as the host UID and GID and keep the home's default
mode. The image has no passwd entry for that UID, so set HOME to the
home that the hermes user had. At teardown the fixture removes the home
itself, so a file that the runner cannot delete fails the test instead
of accumulating on the host. Windows has no host UID; Docker Desktop
does not enforce ownership on bind mounts there, so the gateway keeps
running as UID 10000.

On OrbStack's Linux kernel, a home written by UID 10000 could not be
removed by UID 501 ("Permission denied" on logs/gateway.log), and a
home written as 501:20 could. The plain exchange passes as the host
user on macOS.
The fake model server for the live gateway listened on 0.0.0.0, so any
machine on the developer's network could send it requests while a test
ran. A stray request would consume the scripted reply or appear in
main_requests(), and the macOS firewall can ask whether to accept
incoming connections.

The gateway container reaches the fake as host.docker.internal. With a
daemon on this host, as on Linux, the test network's bridge gateway is
an address of this host, so the fake now listens on that address and
host.docker.internal resolves to it. A daemon in a VM, such as Docker
Desktop or OrbStack, does not expose that address on the host, and its
host-gateway forwards to the host's loopback interface, so the fake
listens on 127.0.0.1 there. The fixture chooses by trying to bind the
gateway address.

In OrbStack's Linux VM, a server bound to a network's gateway address
answered a container on that network through the host.docker.internal
mapping. On macOS the bind fails with EADDRNOTAVAIL, and the plain
exchange passes over 127.0.0.1.
The gateway fixture yielded as soon as the gateway logged
"Matrix: joined <room>". The Matrix adapter joins the invited room
shortly after it connects, while the gateway is still in startup
restore. During startup restore the gateway queues inbound messages and
replays them when restore finishes, so a test's first message could
reach the gateway through that replay. A scratch live test that sent
its message as soon as the fixture yielded was queued in two of three
runs.

The replay can reach the adapter while the adapter's original dispatch
of the same event still holds the session guard: for Matrix, the guard
stays held during the typing-stop request after the handler has queued
the event. The adapter then handles the replay as a second message
while the session is busy, and the bot's first reply is the "Interrupting
current task" notice. One native run of the oversized-media test failed
this way.

The gateway logs "Press Ctrl+C to stop" after startup restore has
finished. The fixture now waits for that line as well as for the join,
so a test's first message is dispatched directly. With this check the
same scratch test was dispatched directly in three of three runs.

When a test that uses the fixture fails, a report hook adds the last 200
lines of the gateway log to the failure report. The fixture deletes the
gateway home at teardown, so the log of a failed run was lost before.
LiveGateway now includes the home so that the hook can find the log.
iainlane and others added 27 commits October 13, 2026 08:33
Two helpers check whether a reply's HTML body starts with an
<mx-reply> fallback. _has_reply_fallback decides whether the plain-text
quote is stripped from the message, and extract_mx_reply_quote returns
the quote as reply_to_text. Their checks disagreed.
_has_reply_fallback required format "org.matrix.custom.html" and a
bare "<mx-reply>" tag. extract_mx_reply_quote ignored format and
accepted any body that started with "<mx-reply". When the tag had an
attribute, as in <mx-reply data-x="1">, or the event had no format
field, and the plain-text quote had no pill, the message kept the
quote and reply_to_text repeated it. The model then saw the quote
twice.

Add starts_with_mx_reply(content) to reply_context.py and call it from
both helpers. It requires the HTML format and matches an <mx-reply>
start tag with or without attributes, because the spec defines the
fallback as a formatted_body that "begins with an <mx-reply> start
tag". The match ignores case because the quote extractor's HTML parser
lower-cases tag names, so the check accepts every tag that the
extractor reads as <mx-reply>. extract_mx_reply_quote now takes the
event content instead of the formatted body because it needs the
format field.
Busy-text debounce updates the reply anchor to the latest inbound message
but did not record the earlier message IDs. A new Matrix thread session
therefore fetched an earlier trigger as thread history and included the
same text again in the current user message.

Record each previous anchor and incoming batch's merged IDs before the
anchor changes. Thread-history preparation can then exclude every event
already represented in the current turn. Input without a new anchor
keeps its existing anchor and still contributes its merged IDs.

Extend the real model-preparation invariant to both ingress and busy
batching. The busy case repeated the first trigger before this change
and now excludes it. Independent full-event expectations also reproduce
six missing-ID cases and pass after the fix. The pending-merge contract
checks unknown, unauthorized and authorized quoted authors together with
media and merged IDs.
The room-context prerequisite adds channel_state as the final positional
MessageEvent constructor argument. Threading inserted two fields before
that argument, so an existing positional room-state dictionary became
the reply-author authorization value and the event lost its room state.

Append the new threading fields after channel_state. Existing positional
construction keeps its original meaning, and the new authorization
field defaults to None. A direct constructor probe reproduces the old
misassignment and passes after reordering; all 330 tests in the seven
adjacent event, thread and gateway files pass.
Resolved reply parents, cached edits and thread history removed every
leading Markdown quote block. Modern Matrix clients send replies
without a fallback, so an ordinary user-authored quotation disappeared
from the context before the model received it.

Move the adapter's existing fallback detection and splitting helpers
into the shared reply-context module. All context paths now strip only
a recognized legacy sender pill or a formatted mx-reply fallback. Edits
use the replacement content to identify its formatting correctly.

One parameterized behavior contract checks all three context paths
against user quotations and both legacy fallback formats. The three
user-quotation cases fail on the original head and pass after the fix.
A busy-debounced turn can begin with the thread root and finish with a
later reply. The event includes both IDs, but thread backfill excluded
only relation chunks and always appended the separately fetched root.
The model received the root text twice: once in history and once in the
current message.

Check the excluded event IDs before resolving the root, just as for
thread replies. Extend the real intake, debounce and model-preparation
contract to batches that begin with their thread root. The busy case
reproduces the duplicate on the original head and passes after the fix;
ordinary thread roots still appear once in earlier history.
The shared splitter permits a caller to transform the reply text, so
joining the transformed halves does not reproduce the original body.
Describe the actual constraint: the quote block includes its separator
and remains unchanged while the caller transforms the reply text.

The merged event IDs also include earlier busy-debounced input. That
path uses the latest message as its reply anchor, so the field comment
must not promise that message_id always remains the first input's ID.
Threaded outbound sends always point their fallback reply at the thread
root, even after the adapter receives or sends a newer event. Explicit
replies are also labelled as fallback replies. The resulting relations
lose the natural message chain in clients that do not render threads.

Exercise an accepted inbound event, a split text response, an explicit
reply, and encrypted and plain media sends. Both tests fail on the
current adapter because every fallback target remains the root.
Thread relations previously labelled every outbound message as a
fallback, including messages with an explicit reply target. That
misrepresented genuine replies and could alter how clients render them.

Set is_falling_back to false for an explicit reply and keep its target,
even when the target is outside the current thread. For a plain thread
send, use the supplied fallback target or the thread root and mark it
as a fallback.
Matrix sends previously pointed every thread fallback at the root. A
client without thread rendering therefore lost the order of later
messages, especially when a response was split or media followed text.

Track the latest event per room and thread after an inbound message or a
successful outbound send. Use that event for the next fallback, with the
root as the first anchor. Explicit reply targets still take precedence
and remain genuine replies. The tracker is bounded and belongs to the
Matrix plugin, so the gateway core gains no platform-specific state.

The focused tests cover split text, explicit replies, encrypted media,
plain media and multiple images. The Matrix client-server specification
recommends the latest known message for this fallback target.
A separate Matrix client now sends two messages in the same thread and
checks the relation on each gateway reply. Each reply targets the
current inbound event and uses genuine reply semantics, including
after an earlier bot reply in that thread.
The gateway anchors every Matrix final response on the triggering
event, so send() passed that event as reply_to for each chunk of a
split response. Every chunk became a genuine thread reply with
is_falling_back set to false, and Element rendered a quote of the
user's message above each chunk. Streamed responses had the same
problem: the stream consumer anchors each new chunk on the previous
chunk, and later messages in the turn on the triggering event again.
In auto-thread rooms the synthetic thread root is the triggering event
itself, so the first reply in every new thread quoted its own root.

The tracker now records, for each room and thread, whether Hermes sent
the latest event and which event Hermes last replied to. A send
anchored on the thread root, on Hermes's own latest event, or on an
event that already has a reply in the thread is a continuation. It
sets is_falling_back to true and points at the latest known event, so
only the first message of a response remains a genuine reply. The
decision stays in the Matrix plugin, and the platform-neutral chunk
anchoring in the stream consumer is unchanged.

Unit tests cover a split final response with reply_to, the buffered
and live streamed paths, and a reply anchored on the root. A live
Synapse test limits the gateway to 500-character messages and checks
that only the first chunk of a threaded answer quotes the question.
Before this change the second chunk also quoted it.
Outbound thread relations accepted an explicit fallback anchor under
two metadata keys, matrix_thread_fallback_event_id and
thread_fallback_event_id. Nothing sets the unprefixed key, so remove
it. The prefixed key remains because Matrix thread creation sets it to
anchor the first reply of a new thread on the new root.
A threaded response quotes its triggering event once, and the later
messages of that response are thread fallbacks. The tracker recognised
a continuation only when reply_to was the event that Hermes had most
recently replied to, or Hermes's own event while it was still the
latest event in the thread. Any other genuine reply or inbound post
replaced those values, so the rest of the response quoted its trigger
again.

That happens on ordinary gateway paths. A busy acknowledgement for a
follow-up is a genuine reply to the follow-up, and the running turn
then sends its next segment anchored on the original question. With
thread_sessions_per_user, two sessions can answer in one thread at the
same time. The stream consumer anchors each head chunk on the previous
chunk, and another user can post between the two sends.

Keep, for each thread, a bounded list of the recent events that Hermes
has given a genuine reply to and of the events that Hermes has sent. A
send whose reply_to is in either list is a continuation. The fallback
target is still the latest known event in the thread.
An admitted mention can resume a room discussion after messages were
ignored by the mention gate. A new thread session also needs the earlier
messages from that thread before the model answers its first turn.

Fetch the earlier events through the live Matrix client, bounded by the
triggering event and the configured limit. Add the context through the
adapter's turn-context hook after admission. Keep room catch-up within
its prior timeline boundary, skip unrelated thread events, and retain
quoted text as external input.

Consolidate the initial implementation and its dependent migrations to
the current turn-context hook. The original implementation used the
removed room-state and string-returning history interfaces, so those
commits could not run individually on the updated parent.

Co-authored-by: Jiya (Hermes Agent) <agent@hermes-agent.com>
Co-authored-by: Iain Lane <iain@orangesquash.org.uk>
Co-authored-by: Cursor <cursoragent@cursor.com>
The user guide said that every message in a thread where the bot
already takes part starts a turn, so those threads have no catch-up.
With `thread_require_mention: true`, those threads drop messages that
do not mention the bot, and an admitted mention includes them as
catch-up like any other mention-gated thread.

Qualify that sentence, and describe the first turn of a new thread
session, which includes the thread root and the earlier thread
messages without stopping at the bot's own replies.
The Matrix adapter reads two catch-up limits from config.yaml:
matrix.thread_backfill_limit and matrix.room_backfill_limit, each 20
by default. Only the thread limit had an entry in DEFAULT_CONFIG.

The dashboard builds its config form from DEFAULT_CONFIG, and GET
/api/config fills unset keys from the same defaults. The Matrix
section of the dashboard therefore offered the thread limit with its
default of 20 but had no field for the room limit, so a user could
change the room limit only by editing config.yaml. Config loading and
`hermes config set` were unaffected: loading keeps user keys that have
no default, and `config set` validates only the first segment of a
matrix.* path.

Add room_backfill_limit beside thread_backfill_limit with the default
that the adapter applies. The dashboard now shows it as a number field
set to 20. No _config_version bump is needed because a new key reaches
existing configs through the ordinary deep merge.
Mention catch-up scans back from an admitted mention and stops at the
bot's last message in the room or thread. The gateway also posts status
notices there that do not answer a turn: restart and online notices,
background-process and update notifications, heartbeats and tool
progress. A notice posted after the bot's last reply ended the scan
early, so the messages that the gate dropped before the notice never
reached the agent.

The gateway already marks these sends with
metadata["non_conversational"], but only for Discord. Discord keeps the
IDs of marked messages in a sidecar file because it cannot annotate a
message. A Matrix event can include a namespaced content key, so the
mark can stay with the event on the homeserver. It remains after a
gateway restart, and in an encrypted room it is part of the encrypted
payload.

Mark status sends for Matrix as well. The adapter writes
com.nousresearch.hermes.non_conversational into the content of each
marked message, and the room and thread history readers skip events
that include it. A notice therefore neither ends the scan nor appears
in catch-up or in the history for a new thread session, which is how
Discord treats its status messages. The readers check the original
event content, so a notice that the gateway edits later keeps its mark.
fetch_mention_context said that the transcript already contains the
event where catch-up stops. That is not true on the first room turn
after `/new`: the new session's transcript is empty, but the scan still
stops at the bot's reply to `/new`. It is also not true when each
mention starts its own automatic thread, because the previous mention
belongs to another thread's session.

The boundary itself is the intended one. Mention catch-up recovers the
messages that the gate dropped since the previous turn in the room or
thread. The messages before that turn belong to the conversation that
the reset discarded, and scanning past it would bring that
conversation into the new session. Stopping at the start of the new
session would be wrong too. A reset by the session policy changes
only the session store, and the new session starts with the mention
that triggers catch-up, so catch-up would lose every message that the
gate dropped since the bot's last reply. A new thread session is
different: its first turn receives the whole thread, because the
thread is the conversation that the user continues.

Describe the boundary as the previous turn, which can belong to an
earlier session, in the docstring, the PreviousTurnCheck comment and
the user guide. Add a test that pins the room boundary on the first
turn after `/new`.
The gateway marks lifecycle and status sends with
metadata["non_conversational"] on the platforms whose adapters read
the mark, Discord and Matrix, so those notices do not bound a history
scan. The shutdown path missed the mark. The restart announcement to
active chats, the home-channel broadcast and the cron-interrupted
notice all go through _send_notice_logged, which added only the
interim marker.

On Matrix, a room that received "Hermes is restarting" after the
bot's last reply therefore ended mention catch-up at that notice, and
the messages that the gate dropped before it never reached the agent.
The online notice that follows a restart was already marked, so the
scan passed over only half of a restart. Discord had the same gap:
its fallback patterns recognise the restarted and online notices but
not the shutdown notice.

Mark the metadata in _send_notice_logged, which all three sends use.
_non_conversational_metadata decided by platform name whether to mark a
status send, with the set {"discord", "matrix"}. Both adapters are
plugins, and core does not list plugin platforms. Every further
platform that wanted the mark would have had to edit core.

Add reads_non_conversational_mark to PlatformEntry. Discord and Matrix
set it when they register, and the helper reads it from the registry
entry for the platform of the send. The lookup stays keyed on the
platform, not on the adapter object, because a relay transport
delivers Discord sends when no native adapter is connected, and those
sends are marked today. Discord and Matrix sends get the mark as
before, and other platforms keep their metadata unchanged.

Plugin discovery fills the registry, and the gateway runs discovery
before it creates adapters. Tests that expect the mark now run
discovery, as other tests that read registry entries do.
The user guide and the fetch_mention_context docstring said that after
`/new` catch-up starts after the bot's reply, so the conversation that
the reset discarded stays out of the new session. That is true in the
main timeline only. In a thread, `/new` resets the thread's session,
and the next mention is the first turn of a new thread session. That
turn receives the thread root and the earlier thread messages,
including the conversation before `/new`, because the thread is the
conversation that the user continues.

Say that the rule applies to the main timeline, and that the first
message after `/new` in a thread gets the thread history.
Thread catch-up now requires the triggering event and reads history from
its boundary token. The earlier quote-preservation fixture returned an
unbounded relations response without the required boundary, so the
adapter correctly returned no entries before testing the message text.

Provide the context and messages tokens and a genuine thread relation.
Keep all authored-quote and reply-fallback assertions unchanged. The
rebased six-file gate passes 281 tests without retries.
Backfill decrypts an event only after checking its encrypted event type.
The general SDK Event factory dispatches to EncryptedEvent for this
input, but its dynamically assigned deserialize attribute is absent from
the static Event type.

Use the concrete decoder and SDK JSON input type, as the published read
layer already does. This preserves the runtime decoder and lets the
type checker verify the known event kind. The real-SDK reply and thread
contracts cover available and missing keys; the six-file gate passes
281 tests without retries.
Later mentions fetch earlier room or thread messages for the next turn.
A batched message can already contain some of those events, but only the
first-thread-turn path passed their IDs to the history reader. Later
mention catch-up therefore repeated their text above the new message.

Pass merged event IDs through both mention catch-up paths. The room
reader now excludes those IDs before checking the previous-turn boundary,
as the thread reader already does. This keeps the current batch separate
from earlier discussion and prevents an already batched mention from
ending the history scan prematurely.

The real room and thread entry paths reproduce duplicate text before the
fix. The repaired six-file gate passes 283 tests without retries.
The Matrix SDK retries rate-limit responses and connection timeouts
indefinitely by default. A failed request during account or room setup
can therefore stop the whole native module until the outer runner kills
it, without reporting the setup error.

Disable those retries and bound ordinary fixture requests to fifteen
seconds. Apply the same configuration to registration,
restored accounts and the encrypted observer. The fixtures now expose
the SDK error so the failing operation can be diagnosed directly.

A real loopback HTTP server reproduces the repeated rate-limit requests
through registration and restored accounts. Both paths now make one
request and return the expected error.
The first turn of a new thread session gets the thread root, but
_format_history_context cut every entry, the root included, to 1,200
characters. A bot answer whose options come after that point lost
them, so "go with option A" in a thread on it still had no context.
The root is now marked (MatrixEventContext.is_thread_root) and kept
whole, as the reply path already does; other entries keep the cap.

fetch_thread_entries also returned [] when the thread page request
failed, before the root lookup. A new thread session now still gets
the root, which is a single event. Catch-up keeps returning [],
because it cannot tell whether a failed page held a previous turn.

Refs NousResearch#137686
main enabled UP006, UP037, UP041, PLW1510 and RUF024 after NousResearch#125648 and
NousResearch#125697 were written, so their lines failed `ruff check .` once rebased
onto main. Mechanical fixes only: builtin generics, unquoted annotations
(evaluated lazily on Python 3.14), TimeoutError for asyncio.TimeoutError
(the same class), an explicit check=False where the caller already reads
the return code, and a dict comprehension for dict.fromkeys with a
mutable value. No behaviour change.
@HarshitRuwali
HarshitRuwali force-pushed the fix/matrix-thread-root-full-text branch from 5dae0e9 to 17390b6 Compare October 13, 2026 09:01
@alt-glitch alt-glitch added area/install-update Installer, updater, packaging, wheels, doctor and removed area/docker labels Oct 17, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions area/config Config system, migrations, profiles area/install-update Installer, updater, packaging, wheels, doctor area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation 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 sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants