Repository navigation
Conversation
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.
iainlane
force-pushed
the
feat/matrix-thread-create
branch
from
October 1, 2026 09:36
ab191bf to
92f5c14
Compare
This was referenced Oct 1, 2026
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.
iainlane
force-pushed
the
feat/matrix-thread-create
branch
from
October 8, 2026 00:04
92f5c14 to
ec56a54
Compare
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
force-pushed
the
feat/matrix-thread-create
branch
from
October 10, 2026 07:45
ec56a54 to
0a33fe1
Compare
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
force-pushed
the
feat/matrix-thread-create
branch
from
October 11, 2026 00:07
0a33fe1 to
ce7b501
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 throughauto_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_createtool in a newmatrix_threadstoolset. The model supplies the first thread message and eitherroot_text, which becomes a new main-timeline root, orroot_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.threadrelations. 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.
MatrixSessionAccessinread_context.pytherefore 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 reportsMatrix client ownership changedwhen any of them differs.matrix_readkeeps 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:
partialand the root ID, so the model does not send the root again.delivery_uncertainis 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_threadsafewith a 300-second deadline, asmatrix_readdoes, and accepts only the arguments that its schema declares. Likematrix_read, it is deferred behindtool_search, so each model request includes a one-line catalog entry for it, not its schema.Other platforms
matrix_threadsis restricted to Matrix in_TOOLSET_PLATFORM_RESTRICTIONS, sohermes toolsrejects it for every other platform. The tools-config tests check the Telegram refusal.gateway/.display.platforms.matrix.tool_progresstooff, so a tool progress message cannot arrive before the thread messages that a test checks. The fixture also accepts agroupparameter, which adds a third member to the room. No native test expects a progress message.Configuration and documentation
matrix_threadsis part of thehermes-matrixcomposite and is listed inCONFIGURABLE_TOOLSETSas "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 listsmatrix_threads.hermes tools enableandhermes tools disablewith--platform matrixturn it on and off.matrix_threadsrow, and itshermes-matrixrow lists both Matrix toolsets.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
dictannotation. 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.pyis slow under Docker load. Local runs took between 323 and 867 seconds, including the gateway image build.matrix-live.ymlbuilds 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.matrix_threadsin its Toolsets page, becauseDESKTOP_HIDDEN_TOOLSETShides only the Discord and Yuanbao platform toolsets.