Skip to content

feat(matrix): create threads through the live session - #126341

Draft
iainlane wants to merge 141 commits into
NousResearch:mainfrom
iainlane:feat/matrix-thread-create
Draft

iainlane wants to merge 141 commits into
NousResearch:mainfrom
iainlane:feat/matrix-thread-create

Conversation

@iainlane

@iainlane iainlane commented Sep 28, 2026 •

Copy link
Copy Markdown

Stack

All PRs shown target main. Nesting records merge order: each parent must merge before its child. #126273 supplies captured-client read admission and includes the published rich-cache prerequisite. Each prerequisite description lists its earlier dependencies.

Summary

Matrix threads start when a message relates to a root with m.thread, and Hermes created them only implicitly, by replying in a thread that a user had started or through auto_thread. The agent could not start a thread itself, for example to move a long side discussion out of the main timeline.

This PR adds a matrix_thread_create tool in a new matrix_threads toolset. The model supplies the first thread message and either root_text, which becomes a new main-timeline root, or root_event_id, an existing main-timeline message in the current room. Matrix threads have no separate name, so a label is part of the visible root text. Thread replies, edits and withdrawn events cannot become roots, and the tool does not join or create rooms.

The tool reports success only when both the root and the first reply are delivered, because a root alone does not show a thread in Matrix clients. The result then includes the room, root and initial reply event IDs. The adapter marks the thread as one that the bot takes part in only after the first reply is confirmed, so a later unmentioned reply in that thread continues there without widening the room policy. The current turn stays in its original session. A later user reply in the new thread starts a separate thread session.

Delivery uses the adapter's usual formatting and message splitting, with standard m.thread relations. The first chunk's reply fallback targets the root. Each later chunk's fallback targets the latest known event in the thread, which can be a user's reply that arrived between chunks.

The receiving client can change during a call, for example when the adapter reconnects or a profile is swapped. MatrixSessionAccess in read_context.py therefore captures the client, its user and device IDs, credentials, homeserver, crypto and state stores, and the profile home before admission. It rechecks them after every await and before each send and each bookkeeping step, and it reports Matrix client ownership changed when any of them differs. matrix_read keeps the current read admission and adds the captured-session checks to its existing rich-cache resolution path. In an encrypted room, each message is encrypted with the captured client, and when a room turns out to be encrypted after admission, the send is refused and no plaintext is sent.

Failures keep the IDs that the homeserver confirmed:

  • If the root was delivered and the first reply failed, the result reports partial and the root ID, so the model does not send the root again.
  • A message that the homeserver accepted before an ownership check failed is still recorded in the reply context cache and the thread fallback tracker, under the user ID captured with the session. The adapter ignores its own events during sync, so it would otherwise never learn about that event.
  • delivery_uncertain is set only after the send request has started, when the request is canceled, times out, loses its connection or receives a 502, 503 or 504 response. A reverse proxy can return those statuses after the homeserver has stored the event. A failure during preparation, such as an encryption-state lookup, a Megolm session share or the admission recheck, is an ordinary failure, because no request was made.

The tool schedules its work on the gateway loop through safe_schedule_threadsafe with a 300-second deadline, as matrix_read does, and accepts only the arguments that its schema declares. Like matrix_read, it is deferred behind tool_search, so each model request includes a one-line catalog entry for it, not its schema.

Other platforms

  • matrix_threads is restricted to Matrix in _TOOLSET_PLATFORM_RESTRICTIONS, so hermes tools rejects it for every other platform. The tools-config tests check the Telegram refusal.
  • This layer changes no file under gateway/.
  • The shared live-test gateway fixture now sets display.platforms.matrix.tool_progress to off, so a tool progress message cannot arrive before the thread messages that a test checks. The fixture also accepts a group parameter, which adds a third member to the room. No native test expects a progress message.

Configuration and documentation

  • matrix_threads is part of the hermes-matrix composite and is listed in CONFIGURABLE_TOOLSETS as "Matrix Threads". An unsaved Matrix configuration and a saved [hermes-matrix] list enable it. A saved Matrix list of individual toolsets enables it only when it lists matrix_threads. hermes tools enable and hermes tools disable with --platform matrix turn it on and off.
  • The toolsets reference has a matrix_threads row, and its hermes-matrix row lists both Matrix toolsets.
  • The Matrix guide describes matrix_thread_create: the two kinds of root, which events cannot be roots, the partial-delivery result, and the separate session for later replies in the new thread.

Current captured upstream composition

The current four-contribution layer follows the delivered read-access producer #126273 at 0ce0278e1280d1421b934d96fb3f2d0251c9bcc7. Its complete read-access supporting modules and the published #125660 HTTP API/client factory mechanism remain exact. No supplier history or completed supplier gate is repeated.

Review the four current contributions separately from that prerequisite.

Every cumulative original snapshot is composed through a complete three-way join. The adapter resolution preserves the complete incoming captured-client send callbacks and uses the current builtin dict annotation. The read-context resolution preserves the complete regression for a client change during the root fetch; the other conflict arm only removes an EOF blank line. Every other production path follows the automatic join.

The first current complete thread-tool test file passed 31 assertions but failed 51 cases during resource teardown. Registry dispatch creates persistent event loops in its executor threads. The test fixture now tracks the loops returned by the actual registry getter and closes those test-owned loops during teardown. That correction is folded into the first contribution, which introduces this test module. Every original assertion, parameter table and production statement remains unchanged. The complete failed file, including all 31 partial passes, is excluded from the successful count.

The four contributions preserve their original author headers, dates, timezones and order. The first commit also explains the current composition, fixture teardown and UTF-8 repair. The other three commit messages and diffs remain unchanged. The 137-record signed prerequisite remains unchanged. The repair is folded into the feature commit, and all four commits have renewed signatures. Changed-line whitespace checks pass.

Current validation

Thread creation now uses the adapter's UTF-8 length function for both the root limit and reply splitting. Previously CJK or emoji text could pass the root limit or produce a reply larger than the byte budget. The two parameterised regressions exercise registry dispatch, session access and the real SDK request path with a synthetic HTTP response transport.

The complete thread-tool file passed 82 cases before this repair. All four new cases failed for the expected byte-limit errors on that source, while the existing 82 cases still passed. The repaired complete file passes all 86 cases through scripts/run_tests.sh, with one worker, no retries and strict resource, runtime, unraisable and thread warnings. The signed head has the exact tested Git tree. Ruff and type checking pass for both changed files. The test file retains six existing formatter differences and introduces none.

Previous validation

The following results describe the preceding head, 0a33fe1cfdb1673ad6e140275952bf40f77d5005. They remain evidence for that revision; the fresh validation above covers the UTF-8 repair. The live homeserver suite and broader seven-file suite have not been rerun on the repaired head.

The canonical source-qualified result is 524 passes across seven complete files, with six host skips. Six earlier complete resource-clean files contribute 442 cases. The corrected complete thread-tool file contributes 82 fresh cases. Each complete child finishes with zero strict resource, runtime, unraisable and thread warnings and zero retries. Both executions authenticate 9,233 tracked Python, configuration, lock and runner inputs before and after execution, and the canonical runner is restored exactly. The six unchanged files are qualified to final source by the sole test-module difference. The initial failed whole thread-tool file and the earlier pre-collection sandbox failure remain preserved and excluded.

The complete six-case live Matrix thread-creation module passes with zero strict resource warnings and zero retries. It exercises new and existing roots in plain and encrypted rooms, denied delivery and the encryption-state transition. The run used d3a65edf130ac434d327b4b6bc1d91ab70a59966, with 8,423 tracked Python and runner inputs authenticated before and after execution. Package metadata, lock and current PM image inputs are separately bound. The final source differs only in the separately rerun SDK test module. All live test, production, configuration and image inputs remain byte-exact; this is a source-qualified live result, with no claim that the final head was executed again.

The complete seventeen-path type scope contains 126 findings, compared with 139 on the old source and 127 on the current prerequisite. Every full lexical span, column, statement-relative offset and enclosing statement AST occurs in the original/provider occurrence union with its multiplicity. The affected complete thread test module has zero findings after its fixture correction; the other sixteen complete source scopes are byte-exact their initial execution inputs. Ruff reports no findings. Thirteen files retain inherited formatting differences, so type checking and formatting remain non-green. No warning, type rule or assertion is weakened.

The following historical results remain qualified to their recorded revisions. They do not establish current hosted CI or whole-suite success.

Historical review scope and verification

This branch contains all four original thread-creation contributions above the published read-access prerequisite. Literal authors, committers, dates and full commit messages are preserved. The current context mixin, discovery and inspection methods, rich history cache, room classification, requester policy and native harness modes remain in the composed branch.

The thread contribution comparison isolates these four contributions from the prerequisite.

Through scripts/run_tests.sh, 524 cases pass across seven complete files with six skips, using actual SDKs, one worker and no retries. The final subsequent change combines the native fixture display settings. All eight mode configurations parse without duplicate keys; production and all seven tested files are unchanged. The complete 79-case thread suite retains every original assertion and parameter table. It covers registry dispatch, profile isolation, refusals, partial delivery, real SDK encryption admission, send uncertainty, confirmed-event caching after a reconnect and cancellation. Related whole files cover read context, read tool dispatch, discovery, room inspection, adapter behaviour and toolset configuration.

A new regression reproduced a second request on a stale client when a reconnect occurred during the root-history fetch. The owner is now checked before the next fetch, so the read returns the ownership error after exactly one request.

The independent review also identified policy changes during room classification and the final encryption-state lookup. Three regression cases reproduced an unauthorised send. Synchronous validation of the current room and requester policy now follows both awaits and prevents all three sends.

Scoped Ruff reports no findings. All 52 type diagnostics match complete statements in the prerequisite or original thread source, with no new diagnostic. Existing full-file formatting differences remain; committed whitespace checks pass.

The earlier six Synapse/gateway native cases passed at ab191bfc7d. Those receipts remain historical and do not establish fresh live acceptance of this composition.

Known limitations

  • test_thread_create.py is slow under Docker load. Local runs took between 323 and 867 seconds, including the gateway image build. matrix-live.yml builds the image in a separate step and gives each file 300 seconds. The file's time without the build has not been measured on a CI runner, because the live Matrix workflow has not run upstream for this PR.
  • The desktop app lists matrix_threads in its Toolsets page, because DESKTOP_HIDDEN_TOOLSETS hides only the Discord and Yuanbao platform toolsets.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) area/sessions Session lifecycle, resume, persistence, history 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 sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 28, 2026
Matrix permalinks change with each triggering event. Rendering one in
the pinned session prompt made the prompt stale or changed its bytes
every turn, which breaks conversation prompt caching.

Put the link in the current model-facing user message and remove it from
the durable transcript, including queued turns. Respect effective PII
redaction when adding the note. Percent-encode room IDs, event IDs and
the via server so delimiters cannot change the matrix.to URL structure.

Adopt the published upstream source-note prefix removal when
composing this prerequisite with current upstream. Keep the complete
Matrix and Discord transport selection, prompt-caching contract and
durable-transcript behaviour. The same removeprefix mechanism is
already published by the original source-note producer.
The live Matrix exchange now checks the model request for a permalink to
the event sent by the independent client. It also verifies that the link
is absent from the system prompt, which must remain stable across turns.
Record the exchange duration separately from container setup time.
Room version 12 IDs have no server component. A permalink built only
from the room ID therefore omitted the server hint, which can prevent a
client outside the room from locating it.

Use the joined bot account's server when the room ID has no domain. The
builder still leaves via absent when neither server is known.
A room ID can identify a server that no longer participates in the
room. Its server may also include a port or IPv6 address, which the
last-colon parser truncated to an invalid via hint.

Use the joined bot account's server for inbound source links. When only
a room ID is available, parse its full server after the first colon.
The same parse also corrects the room's session scope.
In a thread, every turn's source link pointed at the thread root. The
root was chosen as a stable anchor while the link lived in the pinned
session prompt, which is per thread. The link now goes on each user
turn, so the reason no longer applies, and the model never received the
ID of the message that triggered the turn. A request to cite "this
message" produced a link to the root, which may be another user's
message.

Build the per-turn link from the triggering event in all cases. A
matrix.to link to an event inside a thread opens that thread in current
clients, and the session prompt already gives the thread root.
A room-ID permalink needs via servers so that a reader outside the room
can join through one of them. The link carried only the bot's server.
When that server was unreachable from the reader's server, the link
failed.

Choose up to three servers as the Matrix specification recommends for
room-ID permalinks. The first is the server of the highest-power joined
member at power level 50 or above; room version 12 creators count as
the highest power. The rest are the servers with the most joined
members, and IP literals are never chosen. The adapter reads members,
power levels and the create event from the mautrix state store, which
sync keeps current, so building a link makes no request. When the
store has no joined members for the room, use the bot's server and then
the room ID's server.

Server ACLs are not applied, because the state store does not keep
m.room.server_acl. Move the builder and the selection into a sibling
module, and emit one via parameter per server.
The session prompt stopped rendering the Matrix source link when the
link moved to the current user turn. Two prompt tests still asserted
that the prompt omitted the link when it was unset or redacted. No code
path produces that output any more, so neither test could fail because
of this feature. The prompt-stability test covers the remaining
contract, and the per-turn tests cover the note and its PII
suppression.

Restore the comment on the per-turn notes. strip_inbound_source_note
removes a note only when it is the first thing in the message, so a
later prefix added outside the notes would leave the note in the
persisted transcript.
Choosing source-link via servers made every inbound message await
get_power_levels and get_create on the client's state store. The
project-context isolation and voice tests gave the adapter a MagicMock
store with only get_members awaitable, so all six inbound tests in
those files failed with "'MagicMock' object can't be awaited".

Production always uses mautrix's MemoryStateStore. Give both fakes a
real MemoryStateStore in which the bot and the sender have joined the
test rooms. The real store implements every state query, so these tests
keep working when the adapter starts reading other room state. The
stores are filled with MemoryStateStore.deserialize because the helpers
are synchronous and some are called inside a running event loop.
The effective-state parent added a description to the matrix_read limit
argument: "Maximum number of events to read". In this layer the same
argument also bounds the members kind, which returns members rather
than events, and the pins kind. Say that the limit applies to events or
members so the model can tell that it bounds member lists too.
The merged matrix_read description said that events are listed oldest
first and that `errors` lists events that could not be decrypted. Both
sentences came from the history reads. A pins read returns events in
the order of the room's m.room.pinned_events list, and its errors also
cover pins that could not be fetched, are not visible messages, came
back as another event, or whose edit was redacted. A model that read
the description could tell a user that a deleted pin was an encrypted
message.

Limit the ordering sentence to room and thread reads, say that pins
follow the room's pinned order, and say that `errors` lists events that
could not be read or decrypted, with the reason. History reads already
returned non-decryption errors such as "event content changed", so the
wider wording is also accurate for them.
Room classification controls the Matrix room allowlist and mention
gates.
The previous member-count shortcut classified a bot-only room as a DM,
and an m.direct entry classified larger or unreadable rooms as DMs.
Those rooms could bypass both gates.

Classify a room as a private bot chat only when the bot and exactly one
other user are joined. Request JOIN membership explicitly: mautrix's
state store includes invited users by default. The state store may also
contain only a subset of a room's members, so use its joined IDs only
when has_full_member_list confirms completeness. Otherwise query the
homeserver with get_joined_members and apply room rules if that fails.

Keep m.direct as room identity metadata, without using it for
authorisation. Document the resulting allowlist and mention policy.

Tests reproduced the bot-only, bot-absent, multi-user, invited-user,
partial-cache and unknown-membership cases before their fixes.
The DM tests marked m.direct account data but did not supply joined
members. The two-person classifier therefore treated those fixtures
as rooms, so their threading assertions tested the wrong policy.

Give the fixture a full joined member list with the bot and one other
user. Keep the three-member variant for room behaviour. The mention
and threading tests now exercise the intended DM policy.
A room is a private bot chat only when the bot's user ID is one of its
two joined members. With an access token, the adapter replaces the
configured MATRIX_USER_ID with the ID that whoami returns. After a
password login it kept the configured string. Synapse accepts a login
identifier whose localpart differs in case from the registered user, so
a configured @Hermes:example.org logs in as @Hermes:example.org. The
membership check then never matched, and every one-to-one chat followed
room rules: with MATRIX_ALLOWED_ROOMS set its messages were dropped,
and otherwise they needed a mention.

mautrix's login stores the homeserver's user ID in client.mxid. Copy it
into the adapter after a successful login, as the whoami path does, so
the membership check uses the ID that appears in the room's member
list.
The Matrix handoff session-key test marked its DM case only through the
m.direct cache. Room classification no longer reads m.direct, so the
DM case was keyed as a group and the test failed. Give the fake client
a joined member list: the bot and one other user for the DM case, and a
third user for the room case.
The environment-variable reference still said that any room with two or
fewer joined members is a DM, and told operators to add a third member
to get room behaviour. A bot-only room, a room without the bot and a
room whose membership cannot be read now follow room rules. Describe
the current rule in the MATRIX_ALLOWED_ROOMS, MATRIX_REQUIRE_MENTION,
MATRIX_FREE_RESPONSE_ROOMS and MATRIX_AUTO_THREAD rows.

The comment in _schedule_pending_invite_joins said that a direct invite
missing from m.direct is misclassified as a group. m.direct no longer
affects classification, so drop that clause.
The E2EE crypto store keys every row (the Olm account, Olm sessions
and inbound Megolm sessions) by an account ID taken from the adapter's
user ID. After a password login the adapter now replaces the configured
MATRIX_USER_ID with the homeserver's spelling of it, so the DM check
matches the room's member list. That replacement also changed the store
key. A deployment with a password login, MATRIX_USER_ID differing in
case from the registered user, and a stable MATRIX_DEVICE_ID would open
an empty store after upgrading. It would create a new Olm account for
its existing device and could no longer decrypt history. The same
happened on an in-process reconnect, because the second login read the
replaced ID.

Keep the configured ID in its own attribute and use it for the login
identifier and, after a password login, for the store key, as before.
The access-token path keys the store by the whoami answer, as it always
has. The adapter's user ID still becomes the homeserver's spelling on
both paths, and the DM, mention, invite and self-sender checks use it.

The password-login reconnect fixture now disconnects its first real client session before opening the replacement, matching the adapter reconnect lifecycle and preserving every identity assertion.
Mention catch-up stops at the previous turn: the bot's last reply or
the last admitted mention. The backfill parent skipped a redacted event
because it had no body. This layer keeps a redacted event in the scan so
that catch-up can show "[redacted]", and the previous-turn check then
saw a bot event and stopped there.

Redaction strips the non-conversational mark from a status notice, so a
redacted bot notice looked like a reply. When a moderator redacted a
status notice, catch-up stopped at it and left out the gated messages
between the real previous turn and the notice.

The room scan, the thread scan and the thread-root check now use
ends_scan, which never treats a redacted event as the previous turn.
Room catch-up still shows the redaction marker; a redacted thread
message has lost its thread relation and is left out, as before.

test_redacted_bot_message_does_not_end_the_catch_up_scan failed for the
room scope before this change, with "Gated one" missing, and passes now.
The reviewer's scratch test also passes.
prepare_turn_context reads catch-up and thread history through
fetch_thread_history, fetch_room_history and fetch_mention_history.
Those return a MatrixHistoryContext, which the snapshot renders again
at the model-input boundary. fetch_thread_context, fetch_room_context,
fetch_mention_context, _format_history_context and
format_history_context rendered that history to a string once, and
only tests called them. A later layer that used them for model input
would have lost the refresh without any test failing.

Remove them. The tests call the history methods, or
MatrixHistoryContext.prepare, and render the result through a
_rendered helper in test_matrix.py.
When the adapter cannot cache an inbound Matrix attachment and has no
HTTP URL to fall back to, `_handle_media_message` built the event with
`media_urls=None` and `media_types=None`. Two cases reach this: an
encrypted attachment whose download or decryption fails (encrypted
media has no usable HTTP URL), and a media event with neither `url` nor
`file`.

Every Matrix input is now prepared through its context snapshot. When
the session has live state, the gateway filters the turn's native image
paths through `PreparedInboundMessage.retained_image_paths`, which calls
`MessageEvent.authored_media()`. That method takes `len(media_urls)`,
so preparation raised a TypeError and the message never reached the
model.

Pass empty lists instead. `MessageEvent` defaults both fields to empty
lists, and the other readers of these fields already treat an empty
list and None alike, so the event reaches the model with its caption
and no attachment.

The new gateway test callers create real session stores. Register their
database cleanup with a scoped ExitStack so the complete test case closes
every owned handle after its assertions, including replaced stores.
On a multiplexed gateway the adapter caches a quoted image under the
launch home before the gateway routes the room to a profile. The
gateway then moves the file into the routed profile's cache with
`rehome_inbound_media` and rewrites the event's attachment entry.

`MatrixTurnContext.reply_image_paths` returned the `media_path` of the
parent's event-cache entry, which records where the adapter cached the
file. On a routed profile that file had been moved, so vision and the
native image buffer received a path to a missing file and the turn lost
the quoted pixels. A later reply that finds the cached file missing
downloads the image again into the launch cache, and the same thing
happens to the new copy; quoted stickers lose their pixels this way.
When the gateway could not move the file, the routed turn received a
file from the launch profile's cache instead.

Record each quoted image's path from the event's own attachment entry
when `fetch_inbound_context` captures the snapshot. That capture runs
in the routed profile's scope directly after rehoming, so the entry is
the file in that profile's cache. The parent's event-cache entry still
decides whether the quote is current (redaction, read errors or a
changed attachment). An entry that is still in the launch profile's
cache is dropped. `rehomed_media_path`, extracted from
`rehome_inbound_media` without changing its behaviour, decides whether
an entry still has to move.

The new test quotes one image from four turns (launch, routed, routed,
launch) in native and text image mode, with and without a failed move.
Before this change both routed turns used a launch-home path: a missing
file when the move succeeded and the launch profile's file when it
failed.

The new gateway test callers create real session stores. Register their
database cleanup with a scoped ExitStack so the complete test case closes
every owned handle after its assertions, including replaced stores.
Effective-event snapshots replace the earlier string history methods,
but the current prerequisites also exclude messages already included
in a merged turn and distinguish authored quotes from reply fallbacks.
Keep those boundaries in the extracted context mixin and shared readers.

Adapt the inherited contracts to the snapshot API and actual client
request binding. Their encrypted unit fixtures now provide the concrete
SDK decoder, and error results consistently contain string event IDs.
The adapted two-file gate passes 414 tests without retries.
The inbound pipeline is already larger than the project module limit.
Its cache transfer logic has separate filesystem and profile concerns
from the turn orchestration around it.

Move that existing logic into run_inbound_media before changing its
ownership policy. The pipeline and Matrix snapshots import the defining
module, and tests patch the same bindings that production reads. The
six selected ownership and quote contracts retain their behaviour.
A prepared launch-profile turn keeps the file from the quoted event
cache. Routing a later reply to another profile moved that same file
out of the launch cache, so the earlier turn lost its pixels before
model use. The retained event snapshot could not prevent a filesystem
move.

Copy quoted attachments into the routed cache and retain their source.
Publish each copy with an atomic rename so another prepared turn cannot
read a partial replacement. Authored attachments keep their existing
move policy.

The native and text quote contract now checks that every previously
prepared file remains readable throughout launch, routed, routed and
launch turns. Both successful-routing cases failed before the fix; all
four quote cases and the existing authored ownership contracts pass.
When an inbound cache transfer failed or its source file was missing,
the event kept the launch-profile path. Authored attachments then
reached media consumers in the routed profile, even though that profile
had not received the file. Quoted attachments were hidden from vision
but still occupied their original positions in the event.

Remove each failed transfer from the event before media preprocessing.
Preserve the caption and replace an inline reference to the failed file
with an unavailable marker. Rebuild the parallel media lists and quoted
dependency indices so valid siblings retain their original meaning.

The whole-event contract reproduces four failures on the unchanged
parent, with two existing ownership controls passing. All 797 tests in
the combined twelve-file effective-state and media gate pass after the
repair without retries.
The agent can inspect its current room but cannot find other joined
rooms, Spaces or directory users through the live Matrix session. Extend
matrix_read with bounded discovery through the receiving adapter and
its owning client and profile.

Apply room and requester policy before reading candidate room state,
verify live membership, and inspect at most 50 candidates. Bound result
counts and network work, report truncation and failures, and abort if
the owning client changes. Discovery never joins rooms or builds a
local history index.

Advertise the homeserver user directory in the tool description. The
deferred search catalog indexes that description, so a directory query
previously returned no tool even though the schema implemented it.

An independent Matrix client observed model discovery of joined rooms,
a joined Space and a directory user. The public child of the Space
remained unjoined. Session, policy, bounds and ownership invariants also
pass with an isolated Hermes home.

Limit discovery to rooms and users shared with the requester, and bind
admission to the owning connected client. Read room state through the
adapter's effective-state reader under one discovery deadline. Include
the parent-interface migrations in the initial feature so the feature
and its tests use the current identity, state and scheduling APIs.

Integration-note: Builtin tool discovery imports the read tool outside a
Matrix session. Importing DiscoveryRequest at module scope also imports
the Matrix package initializer and adapter before the tool can register.
Parse the request through a local import in the discovery branch so the
read tool registers without loading the adapter. A fresh-process test
checks real builtin discovery, registration and the existing session
error from non-Matrix dispatch.
The native discovery test sets Alice's display name and creates the
rooms that she shares with the bot just before the model searches the
user directory. Synapse updates the directory in the background after
profile and membership changes. On a loaded runner the gateway's search
could run before Synapse had listed Alice, so discovery returned
`users: []` with no errors and the test failed.

The fixture now searches the directory as the bot, every 250 ms, until
Alice is listed with the display name "Alice Discovery". The fixture's
existing 30-second deadline bounds the wait. The discovery code itself
is unchanged; the test now starts the model only after the homeserver
can answer the search.

Delaying Synapse's background work reproduces the failure: with
update_user_directory_from_worker and run_background_tasks_on set to
instances that do not exist, the old test failed with `users: []`,
while the new fixture waited through 36 and 40 searches and the test
passed.
The native discovery fixture failed twice on a loaded runner while it
created rooms. Its set-up clients used a 5-second request timeout with
no retries, so one createRoom that Synapse answered after more than
five seconds raised TimeoutError from the aiohttp request timer, even
though the fixture's 30-second deadline had not expired.

The set-up clients now use a 30-second request timeout, equal to the
fixture's deadline, so that deadline is the only bound. They still make
no retries, because createRoom is not idempotent: a retried request
could create a second room with a pending bot invite, which the gateway
could accept.

The set-up is also shorter. The public room is created first. The
ordinary room, the Space (with its m.space.child event in
initial_state, replacing the separate state request) and the bot-only
room are then created together, and the bot joins both rooms together.
The user-directory wait starts right after the display name is set and
runs alongside room creation. The gateway fixture depends on this
fixture, so the bot joins the live room only later; the wait ends once
Synapse lists Alice through the public room or through a room that she
shares with the bot. The test body keeps its 5-second client.

A scratch probe paused the Synapse container for 7 seconds during the
fixture's first createRoom. The old fixture failed with TimeoutError
from the request timer; the new fixture passed twice.
The live discovery fixture could observe Alice in Synapse's directory
before its initial background rebuild ran. That rebuild clears the
directory tables, so the later model query could receive no users even
though room membership was complete. A failing native trace confirmed
that Synapse returned an empty result before the adapter's shared-room
filter ran.

Wait for the pending directory rebuild updates to finish before setting
up discovery rooms. Bound that prerequisite separately from the existing
room setup and model exchange deadlines. Read the pinned test server's
SQLite database in read-only mode to check the actual update state.

Keep a test-only trace of the bot's raw directory response, cache
completeness, joined membership and discovery results. Include the trace
in failed test output and JUnit properties so another failure can be
assigned to the homeserver or the adapter without extra API requests.
Four routing tests pass a recording transport to RelayAdapter, whose
transport parameter requires the RelayTransport protocol. The fixture
only implements its inbound setter and outbound send, so the four
constructor calls fail static type checking even though their exercised
routing paths work.

Implement the complete structural protocol in the recording fixture.
Unused operations raise an assertion error so an unexpected call fails
the test. Keep the recorded action, platform and send result, and narrow
the captured dictionaries before the existing routing assertions.

The correction changes the inherited test fixture. It preserves the
existing contributor records and all original routing assertions.
The encrypted-file fixture returned a constant serialized payload, so
assigning its uploaded URL did not appear in later serialization. The
Matrix SDK serializes the URL stored on the encrypted-file object. The
adapter currently overwrites the outgoing URL, which concealed this
fixture difference in the existing upload test.

Read the fixture URL when serialization is called. Add an invariant
that compares the complete payload before and after URL assignment,
with only the URL overwritten. Keep the existing upload and routing
assertions.
Matrix receives ordinary m.reaction events, but reads and catch-up only
showed message events. The bundled m.relations summary cannot provide
annotation reactions.

Read bounded annotation children through the live Matrix client.
Decrypt encrypted reactions to verify their type, but use the outer
cleartext m.relates_to for the target and key even if the decrypted
payload specifies another relation. Discard redacted events and
deduplicate by sender and key. Present the actor and target in read and
catch-up context without starting a turn. Mark clipped keys and report
incomplete reads when keys, API failures or bounded pages prevent a
complete snapshot.

A five-second batch deadline keeps a slow homeserver from delaying a
turn for every earlier message. Completed lookups remain visible when
others time out. A separate Matrix client confirms that a reaction
appears in the next mentioned turn. Focused tests cover duplicate and
redacted reactions, encrypted events, long keys, lookup errors, and
the deadline.

Bind the real room-policy predicate in the read fixtures. Read admission
rechecks room policy after awaiting room identity, so the fixture must
provide the same adapter protocol as the production path.
Source-permalink and reaction-context branches each test their own prompt
contract. Their combined adapter adds the trigger permalink to catch-up
messages, so the reaction fixtures need the SDK's asynchronous state-store
methods and complete expected source notes.

Merge both feature histories without changing their existing commits.
Preserve reaction author trust, room and thread context, transcript roles,
cache privacy and the source-permalink encoding and routing rules. Use the
real MemoryStateStore in the affected fixtures and retrieve SessionSource
from the final context tuple item. Check each complete catch-up prompt
against its explicit room, trigger event and via server, including the
first event of a batched turn.

The four composed gateway files cover 343 cases. The affected two files
pass all 264 cases after the fixture adaptation; the unchanged session and
project-isolation files passed their 79 cases. Scoped Ruff reports no lint
diagnostics. Existing formatter differences remain qualified separately.
Live Docker room and thread backfill validation is still outstanding.

This dependent layer preserves both parent histories. Its two live
backfill fixture adaptations do not claim complete original 858 delivery.

Historical composition qualification

The preceding message records the original two-parent composition.
This successor reapplies its fixture integration after the individually
preserved prerequisite, SDK and reaction contributions on captured main
648a309. Its history has one parent.
The original composition message remains complete for provenance; this
commit does not create a merge or establish live Matrix acceptance.
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 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.
Matrix turns could read message history, but the agent could not
inspect the current room state, joined members, power levels or pinned
messages. Those details are available from the receiving bot and can
affect what the agent should say or attempt.

Extend the Matrix-only read tool with bounded inspection kinds. Fetch
state and members through the owning adapter, apply its existing room
and requester policy, and resolve pinned messages with the same
decryption path as event reads. Report missing pin content per event
so the other pins remain visible. Include the event type and creator
override when presenting permission thresholds.

A separate Matrix client verifies that the model receives the room
details and pinned message from a disposable Synapse instance.

Use the effective-state parent's client-events decoder and gateway
scheduling path throughout inspection and its tests. Recheck admission
after identity resolution, derive permission defaults from raw state,
and preserve cancellation while refreshing pinned content. The feature
and these interface migrations form one change so the initial commit
uses the current prerequisite APIs.


Read fixture worker-loop lifetime correction:

The read-tool tests dispatch through executor threads. Each worker can
create a thread-local event loop, but the fixture leaves that loop open
when the worker exits. Its selector sockets then produce strict resource
failures. The complete standalone file reproduces two such failures.

Use the published poll fixture dispatcher for all seven worker calls.
It closes the worker loop in the same thread after dispatch returns or
raises. Keep every behavioural assertion and the existing cancellation
sequence unchanged. Production code and the prerequisite history do not
change.
The fake HTTP API discarded the client_session supplied by the Matrix
adapter, so disconnect closed a mock while the real aiohttp session was
left open. A reconnect test also replaced disconnect with a stub and
left its new connection open after checking the guard. Strict resource
warnings expose both leaks.

Preserve the supplied session in the fake API. Use a wrapping disconnect
spy in the reconnect test and disconnect the replacement client after
the existing call assertion. The fake API and reconnect fixture predate
this branch, so this focused fixture maintenance is separate from the
corrections folded into their campaign contributors. Production code
and all original assertions are unchanged.

The prebuilt Client fixtures also use the already published factory from
reaction-watch commit a58c137. That factory assigns the supplied HTTP API
to the prebuilt client, so disconnect closes the same real session that
the adapter created. This branch predates those fixture bindings. Only
the factory and its fourteen existing constructor assignments are
propagated; all production code and assertions remain unchanged.
Matrix edits and redactions can arrive while context is being fetched or
while media is being prepared. Cached reply text could therefore disagree
with the event state that was current when the model received the turn.
Encrypted replacements also needed validation before changing the text.

Track effective originals and replacements, retain event dependencies
across asynchronous boundaries, and refresh admitted room and thread
history before model input. Use the shared history decryption helper and
validate encrypted edit ownership before applying a replacement. Platform
hooks retain the references needed to render the final input on the event
loop, including quoted attachments and unavailable-parent markers.

Include the feature's API migrations and their matching unit and native
fixtures in this commit so the baseline uses the current prerequisite
interfaces. The remaining boundary fixes and test refinements stay in
separate commits.

Bind the real room-policy predicate in the read fixtures. Read admission
rechecks room policy after awaiting room identity, so the fixture must
provide the same adapter protocol as the production path.

Preserve the required upstream builtin typing and unused-import cleanup
while adding the original input revalidation callbacks. Media merging
uses the contribution event method, which pads the aligned inline flags.

The new gateway test callers create real session stores. Register their
database cleanup with a scoped ExitStack so the complete test case closes
every owned handle after its assertions, including replaced stores.
@iainlane
iainlane force-pushed the feat/matrix-thread-create branch from ec56a54 to 0a33fe1 Compare October 10, 2026 07:45
The agent can read Matrix conversations but cannot start a thread for a
separate discussion. Thread creation needs the live gateway's client,
room policy and encryption state so it can report delivered event IDs
without changing the current conversation's routing.

Add a Matrix-only toolset that creates a visible root or replies to an
existing main-timeline event. Capture the session's ownership and check
room and requester policy around asynchronous operations. Report a
partial or uncertain delivery only when a request was sent, and preserve
its event ID when available.

Consolidate the initial implementation and its dependent session-access
and decoder migrations. The reconstructed read parent already uses the
shared history decoder, so those changes must use that interface from
the first feature commit.

Captured upstream compatibility

The current discovery prerequisite uses the builtin dict annotation in
the send method and removes one trailing blank line from the read-
context tests. Preserve the complete thread-creation access and delivery
callbacks with that current annotation. Retain the added client-
ownership regression alongside the existing tests; no runtime body or
assertion changes are needed.

Thread-tool fixture loop lifetime

The thread-tool tests dispatch registry calls through short-lived
asyncio executor threads. The existing registry bridge creates a
persistent event loop for each worker, and executor teardown leaves
those loops and their sockets open. Track the actual registry loops
created during each test and close them in fixture teardown. The
complete strict-warning run reproduced fifty-one resource failures
before this correction; every delivery, ownership, cancellation and
permission assertion remains unchanged.

Matrix message limits

Matrix now measures outbound text in UTF-8 bytes, but thread creation
compared its root with len() and split replies with the default character
counter. Multibyte text could pass the root check or produce an oversized
thread reply.

Use the adapter's length function for both operations. CJK and emoji
regressions exercise the registry, session access and real SDK request
path. Oversized roots are rejected before sending, and each reply fits
the byte budget.
The read-tools parent documented `matrix_read` in the toolsets
reference and gave it tests for its `hermes tools` configuration. The
`matrix_threads` toolset has the same configuration: it is part of the
`hermes-matrix` composite, has a row in `CONFIGURABLE_TOOLSETS`, and is
restricted to Matrix. The reference did not list it, and the
`hermes-matrix` row said that the composite adds only `matrix_read`.

Add a `matrix_threads` row to the reference and list it in the
`hermes-matrix` row. The two tools-config tests now run for both
toolsets. For `matrix_threads` they check that an explicit saved
Matrix list without it leaves it off, that `hermes tools` can disable
and enable it on Matrix, and that it is refused on Telegram. Without
its `CONFIGURABLE_TOOLSETS` row and Matrix restriction, native toolset
recovery would add it to every explicit Matrix list, and
`hermes tools disable matrix_threads --platform matrix` would report
an unknown toolset; both new cases fail in that state.
…heck

When the homeserver accepts a message from matrix_thread_create and the
adapter reconnects before the send returns, the ownership check after
the send raises. _send_room_message ran that check before it stored
the event in the reply context cache and in the thread fallback
tracker, so neither recorded the event. Sync does not repair this,
because the adapter drops its own events. The next outbound message in
the thread then used an older event or the root as its reply fallback,
and a user's reply to the delivered event missed the cache.

The event exists in the room whatever happened to the session, and both
structures are in-memory state of this adapter, so record the event
first and run the ownership check afterwards. The tool still returns
the ownership error with the delivered event ID. The cached sender is
the user ID captured with the session access, so a reconnect as another
account does not attribute the event to the new account.
The registry runs every async tool handler on a worker thread's event
loop, and the gateway passes its own loop as the session's transport
loop, so the handler never runs on the owner loop. The thread tool's
branch that created a task when the two loops were the same was dead
code. Its other branch repeated the error handling that
safe_schedule_threadsafe already provides, which the read tool uses.

The tool also compared a room_id argument with the session's room,
although its schema does not declare that argument. The read tool
dropped the same check for that reason.

Schedule the operation through safe_schedule_threadsafe on every call,
keep the 300 second deadline as a named constant, and remove the
room_id check together with the test case for it.
@iainlane
iainlane force-pushed the feat/matrix-thread-create branch from 0a33fe1 to ce7b501 Compare October 11, 2026 00:07
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 11, 2026
Thread creation still counted code points when checking the root and
splitting replies. Multibyte text could therefore exceed the adapter's
UTF-8 byte limit. Admission also omitted the captured client owner and
required room classification from the current room-policy queries.

Apply the byte-limit and admission changes from NousResearch#126341, with its native
SDK regressions and fixture corrections. Keep the existing policy test
for final preparation and the newer poll and sticker history readers.
Close the registry worker loops after each test so the fixture owns its
resources through teardown.

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/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) 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/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants