Skip to content

refactor(matrix): separate room identity and HTML rendering - #133779

Draft
iainlane wants to merge 87 commits into
NousResearch:mainfrom
iainlane:codex/original377-context-extraction
Draft

iainlane wants to merge 87 commits into
NousResearch:mainfrom
iainlane:codex/original377-context-extraction

Conversation

@iainlane

@iainlane iainlane commented Oct 6, 2026 •

Copy link
Copy Markdown

Stack

This PR targets main. Merge #125660 first. The branch is based on its independently accepted signed head 5863966508580908a6058fade62aae9f66dd324f; the upstream diff includes that prerequisite.

Change

The Matrix adapter defines room identity, room policy and HTML rendering beside its transport and delivery code. Move twelve existing context methods into context_mixin.py and the existing rendering declarations into rendering.py. Move the room identity dataclass into the existing room_context.py module. The adapter inherits the extracted context mixin, and its existing method implementations and binding seams remain unchanged.

Update the gateway routing guidance and tests to use the defining modules. All existing test assertions and parameter rows remain unchanged. This is a mechanical extraction of the current implementation. It preserves the current history readers, cache behaviour, room policy, media handling and rendering.

Current frozen-main runner compatibility repair

The branch now reuses the exact published #125660 prerequisite at 5863966508580908a6058fade62aae9f66dd324f, which includes the accepted runner repair from #125438 and frozen upstream 99a45ecc17cc51c0488ec3b8ccf7eaf483f7203d. All 85 signed prerequisite contributions are reused without signing them again. This layer's single original extraction contribution is replayed above that supplier with its complete author and committer headers, dates, time zones and full message unchanged, then signed with the configured key.

Current signed head: e1c8aa0749fb5953356aab5528bd29edd599a8ac. Source tree: 2b911f4fb8ad877b7fb65a9c429afca305a7edcb. Against the saved automatic merge of the old branch with frozen upstream, only scripts/run_tests.sh and its existing shell regression differ. Both match the published prerequisite byte for byte. Every other product, SDK, privacy, fixture and extraction path matches that merge result.

The strengthened shell regression fails on the old public head because strict acceptance is absent from the forwarded environment. The corrected prerequisite preserves HERMES_E2E_STRICT_ACCEPTANCE, Docker endpoint settings and the gateway image while excluding Docker authentication and TLS credential variables. On this exact candidate, three complete files pass through the canonical runner with two workers and no retries: the shell contract and both strict helper files, 28 cases in total. Whole-tree Ruff lint, the changed shell test's type check, shell syntax and repair whitespace checks pass.

The earlier 237-case extraction and static results below retain their recorded source revisions. This runner gate does not repeat those results or establish native-service acceptance. Hosted CI approval and merge acceptance remain separate; the PR remains a draft.

Review the extraction layer against the exact published prerequisite.

Recorded extraction verification

The complete Matrix adapter and media-filename unit files passed the same 237 cases before and after extraction, with actual mautrix models, one worker, strict runtime warnings and no retries. The extracted modules were loaded from the candidate checkout. Scoped Ruff passes before and after. Scoped Ty reports the same 54 findings, with none introduced.

Final corrections add two module-header blank lines and wrap nine typed method signatures. Both complete module ASTs remain identical to the tested candidate; the 237-case result applies to that behaviour. Inherited formatter differences remain in existing and moved bodies.

The complete original contribution message, author and committer metadata are preserved. Its historical validation statements describe the original contribution; the recorded extraction validation is described above. These unit results do not establish native client or homeserver acceptance.

The prerequisite update changes only the inherited import in gateway/run_inbound.py. This branch preserves every file and assertion in its own extraction contribution byte for byte. The 237-case extraction results above are reused for that unchanged contribution; the repaired parent separately passed its scoped 292-case gate on its recorded pinned composition. These results do not establish current-main or native integration acceptance.

Recorded earlier signed head: 4292baababb88e598a8e9cce465e195cbf570fe0.

Current captured-upstream compatibility and verification

This mechanical extraction now uses the current #125660 supplier with its separately credited client-fixture maintenance. The complete original contribution preserves its literal author and committer fields, dates, timezones, full message and all source and test contracts. The branch retains the supplier's 86 signed contributions and reconstructs only this layer's one contribution. Current signed head: 7ec84d5a26ca397000c742e5c71783c77690ecb8.

The extracted context and rendering modules retain every original implementation. Twelve complete Matrix methods move to their defining mixins, with the original five necessary late imports and explicit adapter self annotations. The complete class inheritance, callers and extracted HTML functions remain unchanged. Local joins with fixed upstream 3637c512fc3211bdccb61b7140d09ac1018508ba and frozen 99a45ecc17cc51c0488ec3b8ccf7eaf483f7203d are clean.

The initial complete six-file run reproduced the supplier's session and worker-loop failures. Both failed whole files are excluded from that run's pass total. Four unchanged complete files provide 52 resource-clean cases. After the published supplier's fixture repair, the complete Matrix adapter file passes 210 cases on this pure source. Its 39 existing database-shutdown observations remain explicit. Every assertion is preserved and all 9,180 actual tracked inputs are source-bound before and after that complete child.

The complete read-tool file separately passes 16 resource-clean cases on a runtime composition of this source with the three published worker-lifecycle contributions from #135482. Those contributions remain a logical validation prerequisite and are not included in this branch's source history. The executed runtime source differs from the final runtime composition only in the unused Matrix fixture module; the complete read-tool file, production paths, relevant fixtures, runner and configuration inputs are exact. In total the qualified six-file scope contains 278 passing cases: 68 cases in five resource-clean files and 210 cases in the Matrix file with the recorded database observations. The results are a source-qualified union of complete executions, not one fresh six-file execution on the final head.

The full six-path static comparison reports 52 inherited complete diagnostic occurrences and no introduced lexical, relative-span or statement-AST occurrence. The affected complete Matrix file was checked again after the final client-factory repair; the other five modules are byte-identical to their earlier inputs. Scoped Ruff and changed-line whitespace checks pass. The same five inherited formatter paths remain. Earlier native service results retain their recorded sources and images. Live Matrix services were not repeated for this mechanical extraction. Hosted execution and merge acceptance remain separate.

iainlane and others added 30 commits October 1, 2026 12:14
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.
Matrix rooms often have no m.room.name state event. Identity
resolution used the opaque room ID as the name shown to the agent
even when other occupants had readable display names.

Read member profiles from the state store, with the joined members
API as a fallback when the store is empty. Use the other occupants
to title an unnamed room. Explicit-name detection and DM
classification remain unchanged.
Matrix room identity was cached without reacting to live state
changes. Topic edits, renames and membership changes could leave
stale room context. The agent also missed important privacy or
replacement changes.

Register sync-awaited state handlers that invalidate the identity
cache. Queue one note per change kind for the next ordinary message.
Keep notes through mention drops, commands and text batching. Quote
and bound room-state values before they enter channel context.
Room notes were consumed while the adapter built an event. The gateway
could reject that event for sender policy or intercept it for a paused
session, leaving no note for the next model turn. A queued follow-up
also bypasses normal admission and missed the note entirely.

Consume notes in inbound model-message preparation, which both cold
and queued turns use after their dispatch gates. Rejected and
intercepted messages therefore leave pending notes for the next turn.

Busy-session debounce and pending-message merges also discarded
channel context from later events. Append it during both merges so
accepted context reaches the model. Request joined member profiles
explicitly so invited users do not appear in unnamed room titles.

Runner admission, paused and queued turns, busy merges, room bounds,
coalescing and invited membership have focused regressions.
A Matrix state update can change a member-derived room title, an
explicit room name or the room topic between turns. Those changes
altered the session-context prompt and agent cache signature, which
rebuilt the agent and invalidated the provider's cached prefix.

Render the Matrix room name, topic and same-sender display name from
the session's persisted origin. Keep live state for routing and room
identity. Member changes join the existing per-turn room notes, which
also report explicit name and topic edits.

A regression reproduced prompt and signature drift on the previous
head. It now verifies stable bytes and signatures after a member
rename, a join, an explicit room rename and a topic edit.
Matrix changes lacked a live exchange test, so mocked adapter tests
could miss failures between Synapse, gateway setup, and model-visible
context.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

When a test that uses the fixture fails, a report hook adds the last 200
lines of the gateway log to the failure report. The fixture deletes the
gateway home at teardown, so the log of a failed run was lost before.
LiveGateway now includes the home so that the hook can find the log.
_host_user() compared hermes_platform's os_family() with "windows", but
os_family() returns sys.platform, which is "win32" on Windows. The
Windows branch therefore never ran, and on a Windows host with Docker
the gateway fixture called os.getuid(), which Windows does not have.
Compare with "win32", as the conftest already does with "darwin".

The matrix-live workflow's path filter omitted code that the gateway
image copies and loads at run time: providers/, which agent/ imports;
plugins/ outside the Matrix adapter (the plugin loader, model
providers, memory and context engines); locales/, which agent/i18n.py
reads; tui_gateway/, which gateway/ imports; and skills/, which the
gateway syncs into its home at start-up. A change to any of them could
break the live tests without running the workflow. Add all five.

Rename HERMES_TEST_MATRIX_IMAGE to HERMES_TEST_MATRIX_GATEWAY_IMAGE.
The suite uses Synapse, Ryuk and gateway images, and "Matrix image"
reads most naturally as the homeserver.
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.
A reply to an m.emote message has a fallback that starts with
"> * <@user:server>". The regex that parses the fallback's pill
expected "<" straight after "> ", so it did not match this form. The
reply lost its author (reply_to_author_id and reply_to_author_name were
None), and the quoted text kept "* <@user:server>" at its start.

Accept the optional "* " before the pill. The fallback detector
already recognised the emote form with a second regex that described
the same line, so both now use the one pattern.
Rapid Telegram messages can be combined before dispatch, but one event
has only one set of reply fields. When two messages quote different
messages, batching their text keeps only the first quote. The busy-text
debounce also overwrote the quote ID with a later inbound message ID.

Split ingress batches when their reply contexts differ. Dispatch the
earlier event through Telegram's tracked inbound redispatch queue so a
concurrent disconnect preserves the event for reconnect. A metadata-free
continuation after a near-limit chunk inherits the first chunk's quote,
which Telegram clients can omit from later parts of a split message.

Compare reply context during busy-text debounce as well, and keep the
quote ID distinct from the inbound message that an answer should target.

(cherry picked from commit 348bef0)
Messages that arrive while a turn is running can be merged into one
pending event. merge_pending_message_event joins a follow-up into the
pending slot, and with busy_text_mode queue, _queue_text_debounce first
joins a burst of text into one buffered event. A merged event has one
set of reply_to_* fields, and neither path copied them from the
incoming message. If a user sent "one" and then replied to an earlier
message with "two", the next turn had no reply pointer, so the agent
could not tell which message "two" referred to. The debounce also
dropped a quoted image from the incoming media_urls.

The previous commit, from NousResearch#65526, stops the debounce from merging
messages whose reply contexts differ in any way. That keeps two reply
targets apart, but it also splits a plain message from a reply that
follows it, and merging those two would lose nothing. The debounce has
two places where a queued turn can wait: the pending slot and its own
buffer. Every split uses one of them, and a third message that fits
neither was dropped.

Copy the reply context instead where that loses nothing.
MessageEvent.absorb_reply_context gives an event without a reply
context the incoming one. Both branches of merge_pending_message_event
and the debounce buffer call it, and the buffer now also takes the
incoming media, as _enqueue_text_event does.
MessageEvent.reply_context_conflicts is true only when both events
reply to a message and their reply contexts differ. The debounce keeps
those messages as separate turns, as it already does for messages from
different senders. If the pending slot and the buffer both reply to
other messages, a third message from the same sender joins the buffer.
The buffer keeps its own reply context, so the third message's text
reaches the agent but its reply context does not.

merge_pending_message_event has to store every event in the single
pending slot. When two reply contexts conflict there, the event keeps
the first one, which is the rule that NousResearch#62088 applies to Matrix merges.

NousResearch#66027 made the newest message's reply context replace the older one.
That drops the older reply target, and it clears a quote when the
newest message has none, so this change does not adopt it. Its
coverage of both merge_pending_message_event branches and of the
debounce is kept.

Co-authored-by: Hilo-Hilo <57138830+Hilo-Hilo@users.noreply.github.com>
Busy-text debounce copied incoming attachment paths and MIME types but
omitted media_text_inlined. A false flag means the attachment's text is
not in the message. Losing that flag changes it to the legacy inlined
state when the gateway builds document context.

Pad legacy flag lists to the existing attachment count before appending
new media. Append incoming flags and pad any legacy incoming entries as
well, so each merged attachment keeps its own text-inlining contract.

Consolidate the reply and media checks into two parameterized behavior
contracts that compare complete events. Telegram dispatch checks await
tracked tasks with zero batching delays instead of assuming that a
short wall-clock sleep is enough under runner load.

Three attachment cases reproduced missing flags before the repair and
pass with the aligned lists. The contracts also retain the documented
single-slot fallback for adapters without separate-turn storage.
Matrix thread continuations include an m.in_reply_to pointer for
clients that do not render threads. The adapter treated that fallback
pointer as a user reply, so it attributed the continuation to the
thread root.

Parse relations into explicit reply, thread fallback and edit fields.
Use the parsed reply target for inbound context and the thread root
for session routing. Malformed relation content now yields an empty
relation instead of failing message handling.
A Matrix reply may omit the legacy inline quote. The gateway then knew
only the target event ID and could not tell the agent what message the
user referenced or who wrote it.

Resolve reply targets from a bounded cache or the room event API. Apply
edits to cached text and keep redactions as empty cache entries so a
later reply cannot revive deleted content. Decrypt fetched events when
the room uses E2EE, and label media bodies so filenames are not treated
as spoken text.

Attach the target author's name and authorisation verdict to the inbound
event. The gateway marks fetched content from an unauthorised author as
unverified background and renders Matrix quotes on one line. The adapter
never fetches a remote target for a sender it rejects.

Co-authored-by: Wessel <2385844+weebl2000@users.noreply.github.com>
Co-authored-by: uuedxz <249543386+uuedxz@users.noreply.github.com>
Co-authored-by: Simon Sharp <234941529+simonamsharp@users.noreply.github.com>
iainlane and others added 21 commits October 4, 2026 10:41
An earlier commit in this series appended every incoming event's
channel_context when the base adapter merged a text batch, a pending
message or a busy-session debounce. Matrix room notes were the reason,
but Matrix now attaches its notes when the turn is prepared, so the
change only affected Discord, Slack and relay.

Discord backfills a thread message by scanning back to the bot's last
reply. For two batched messages, the second backfill already contains
the first backfill and the first message, so the merged turn showed
the first backfill twice and the first message three times.

Restore the previous merge, which keeps the first event's context, and
add a Discord test for two batched thread messages.
Room context reached the model through Matrix-specific code in the
gateway core. run_inbound imported the Matrix plugin behind a
Platform.MATRIX check, run_turn read an ad hoc _matrix_room_state
attribute, and session.py pinned prompt metadata, validated resets and
kept a member digest on SessionSource for Matrix only. Plugins must use
a generic surface instead.

The Matrix adapter also kept a second mechanism: notes stashed from live
state events with per-session cursors. On the production path the
runner discarded those notes in favour of the snapshot comparison, so
only tests reached them.

Add BasePlatformAdapter.prepare_turn_context. The gateway calls it
while it prepares a turn, passing the session origin and the
channel_state saved with the latest user row. The adapter returns a
TurnContextUpdate with a note for the user message and a new snapshot.
The snapshot travels on MessageEvent.channel_state and is saved in the
user row's display_metadata, so a change is acknowledged only when the
turn that reported it is saved. An adapter that sets
reports_chat_changes_in_turn keeps the session origin's chat name,
topic and user name in the session-context prompt, which stays
byte-stable while the notes report later changes.

Matrix implements the hook with its existing snapshot comparison. The
live-note stash, its take methods and their tests are gone; the
remaining tests run through a real SessionStore. The state handlers
now only invalidate the cached room identity.

build_session_context no longer pins anything, so tools read the
current chat name through HERMES_SESSION_CHAT_NAME, and the prompt copy
uses session_identity.replace_source. reset_session refreshes the
origin's name, topic and user name from the /reset source for every
platform and keeps the origin's routing fields, which removes the
Matrix guard that could raise after /reset had already evicted the
agent. format_untrusted_prompt_value becomes public because the plugin
uses it.

Assert that the queued channel state and reset result exist before the
original assertions access their fields. Keep every original assertion
and parameter row.

The consolidated runner fixture also drops the two unused attributes.
The room identity read m.room.name, m.room.topic and
m.room.canonical_alias one after another and treated any exception as
"the room has no such state". A timeout, a 5xx or a rate-limit response
therefore looked like a cleared name and topic. The next turn told the
model that the room name was now its room ID and that the topic was
cleared, and saved that snapshot as the acknowledged baseline. The
following successful read then produced a second pair of notes that
restored the real values. The turn hook also forced a fresh read on
every text message, so each reply waited for three sequential,
unbounded homeserver requests.

Only M_NOT_FOUND now means absent state. The identity reads the three
state events and the member list concurrently, and each request stops
after _ROOM_STATE_READ_TIMEOUT_SECONDS. When any read fails the
identity has no room_state snapshot, and prepare_turn_context returns
None: the turn gets no note and saves no channel_state, so the last
saved snapshot stays the baseline. The hook reuses the cached identity
that the adapter resolved for the message's source moments earlier.
The state-event handlers already invalidate that cache when the room
changes, and a restart starts with an empty cache.

Room identity reads use gather with return_exceptions enabled. A
cancelled read therefore returns CancelledError as a result. The state
read previously treated cancellation as usable data, while the member
read attempted to unpack the cancellation object. Propagate
non-Exception control-flow failures before the ordinary read-error
fallback.

The two cancellation rows fail on the original public source. They pass
with the correction, and the existing ordinary-exception controls pass
on both sources.
Tombstone, encryption, join-rule and history-visibility notes came only
from live state events held in adapter memory. They were marked
delivered when the message was prepared, so a turn that failed before
its transcript row was saved lost them, and a restart dropped any that
had not been delivered yet. Name, topic and member changes did not have
this problem because they were derived from the saved snapshot.

Add the join rule, history visibility, encryption and tombstone to the
room snapshot. The identity reads their state events in the same
concurrent, time-limited batch as the name and topic, and the turn hook
compares them with the snapshot saved on the latest user row. Every
note kind is now acknowledged only when the row that reported it is
saved, and a restarted gateway also reports changes that happened while
it was stopped.

A baseline taken from the session origin leaves these fields unknown,
so the first turn of a session records them without a note. A saved
snapshot without the newer fields still counts as a baseline for the
name and topic.
The room-context layer changed both /new call sites to call
`reset_session(session_key, source=source)`, so a reset refreshes the
origin's chat name, topic and user name on every platform. The Telegram
topic-mode test for /new in a group still expected a call with the
session key alone, and failed with "expected call not found" from
8b5e52e onwards, although it passes on main.

The test checks that /new in a group resets the group's own session key
when DM topic mode is on. Expect the group source as well, which is the
contract that the reset call now has.
…sion

The compression-exhaustion auto-reset passes the turn's source to
reset_session, which copies that source's chat name, topic and user
name into the new session's origin. Internal wakes, such as kanban and
watch notifications, build their source from routing fields only, so
all three are None. When such a wake exhausted compression, the new
origin lost its names on every platform. On Matrix the next system
prompt showed the room ID, and the first human turn reported a rename
against the empty baseline.

Pass the source only when the turn came from a person. An internal
wake now keeps the old origin, as it did before reset_session accepted
a source, and a human turn still refreshes the origin with the current
names.
prepend_turn_context_note called SessionStore.lookup_by_session_key
directly on the event loop for every text turn on an adapter that
implements prepare_turn_context. The lookup acquires SessionStore._lock,
and other threads keep that lock while they save the routing index,
for example during a reset that AsyncSessionStore runs in a worker
thread. While a save was in progress, a Matrix text turn stopped the
whole gateway loop until the save finished.

Await the lookup through runner.async_session_store, which runs it in
a worker thread, as the other inbound callers of this method already
do.
The session-context prompt kept the origin's chat name, topic and user
name only for adapters that set reports_chat_changes_in_turn, while the
gateway called prepare_turn_context for any adapter that overrode it.
The two declarations were independent, so two inconsistent adapters
were possible. One with the flag and no hook froze the prompt and never
reported a rename. One with the hook and no flag reported a rename in
the user turn and also rewrote the system prompt.

Remove the flag. reports_turn_context decides both behaviours from
whether the adapter overrides prepare_turn_context, and the hook's
docstring states that an override must report name and topic changes.
Matrix, the only adapter that used either, keeps its behaviour.

The reset test inserts its adapter into the runner adapter mapping,
which requires BasePlatformAdapter. Implement that contract in the
inert fixture and construct the fixture with its real configuration.
Keep the existing turn-context hook and all original assertions.
When a room-identity refresh hit a timeout or a server error on
m.room.name, m.room.topic or m.room.canonical_alias, the identity was
built as though the room had no such state. The source then showed the
alias, the member-derived name or the room ID, and no topic, and the
adapter cached that identity for the identity TTL. A failed member read
had the same effect on an unnamed room, whose name fell back to the
room ID. New sessions and resets in that window took the degraded names
into their origin, and tools saw them, until a later refresh restored
the real names.

Keep the last value read for each room's name, topic, canonical alias
and member-derived name, and use it when the corresponding read fails.
A successful read replaces the value, so a state event that empties or
removes the name or topic still applies. Only M_NOT_FOUND means that
the state is absent. The check also accepts an error that only has an
M_NOT_FOUND errcode, which is how the room-instructions branch
(NousResearch#125676) recognises it, and the values use the same
_room_state_values map as that branch, so the two branches agree when
they meet.

The values live apart from the identity cache because _absorb_sync
clears that cache whenever a sync response includes joined rooms, so
the cached identity is usually gone when a later refresh fails.
Matrix compares the room with the snapshot saved on the newest user
row that has one, under display_metadata["channel_state"]. Compaction
replaced the summarised rows with a summary row that had no snapshot.
When the summarised rows included every snapshot newer than the
protected head, the baseline became an older snapshot in the head (the
first turn's, with the default protect_first_n) or the session origin
once the head no longer protects any user row. The next turn then
repeated a name or topic note that the model had already seen, and it
compared member and security state against stale or unknown values.

Compaction removes the rows that contain the baseline, so compaction now
keeps it. keep_newest_channel_state in
agent/message_metadata.py records the newest snapshot of the original
rows on the summary row (or on the tail row that absorbs the summary)
when the compacted rows no longer have it as their newest. Tail rows
come after the summary row, so a newer snapshot in the tail still
wins. record_absorbed_message, which every fold of one row into
another calls, applies the same rule, so the survivor of a merge of
adjacent user rows (micro-compaction, alternation repair) keeps the
later row's snapshot.

The summary row can be an assistant row, so the gateway now reads the
newest snapshot on a row of any role. CHANNEL_STATE_METADATA_KEY moves
to agent/message_metadata.py because the compressor needs it, and
acknowledged_channel_state is replaced by newest_channel_state.
When the compressor merges its summary into a tail row instead of
adding a standalone row, that tail row must take the newest channel
state from the rows it replaces. No test reached this path, so
dropping the carrier assignment in _assemble_compressed passed the
suite.

Call _assemble_compressed directly for a summary merged into tail[0],
for one merged into the first visible tail row when the head is empty,
and for a tail that already has a newer snapshot, which must win.
Compaction preserves channel state on the summary or merge row that
replaces removed transcript rows. The preparation hook reads the newest
row with channel state without filtering by role, so its baseline can
come from a summary rather than a user row.

Remove the user-only qualification from the adapter hook documentation.
When an explicit reply (m.in_reply_to) had a body starting with "> ",
the adapter treated every leading "> " line as the legacy reply
fallback. It removed those lines from the message text and reported
them as the text of the replied-to message. Matrix 1.13 (MSC2781)
removed reply fallbacks, and current clients such as Element X send a
reply exactly as the user typed it. A user who began a reply with a
quotation therefore lost the quotation, and the agent saw it as the
message being replied to. Element also sets m.in_reply_to, with
is_falling_back, on ordinary thread messages, so a quote at the start
of a thread message was lost in the same way.

Treat the leading quote as a fallback only when the event has one: the
HTML body starts with an <mx-reply> element, or the plain quote block
starts with the quoted sender's pill ("> <@user:server>", or
"> * <@user:server>" for an emote) and ends with a blank line. Older
clients generate both forms. The reply-context parse and the mention
strip under require_mention use the same check, so a quote without a
fallback stays in the text with any bot mention removed, as it does in
a message that is not a reply.

The plain-body check is deliberately narrower than the spec. The
spec's stripping note still says to remove every leading "> " line of
a reply without condition, and that rule cannot tell a fallback from a
quote now that clients no longer send fallbacks. Ruma's
remove_plain_reply_fallback, which matrix-rust-sdk and Element X use,
narrows it in the same way: it strips only a body that starts with
"> <" or "> * <". The HTML check follows the spec's rule for
formatted_body, which applies only when it starts with <mx-reply>.

A hand-typed quote that starts with a pill and ends with a blank line
has the same shape as a plain fallback and is still stripped.
Threaded outbound sends always point their fallback reply at the thread
root, even after the adapter receives or sends a newer event. Explicit
replies are also labelled as fallback replies. The resulting relations
lose the natural message chain in clients that do not render threads.

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

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

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

The focused tests cover split text, explicit replies, encrypted media,
plain media and multiple images. The Matrix client-server specification
recommends the latest known message for this fallback target.
A separate Matrix client now sends two messages in the same thread and
checks the relation on each gateway reply. Each reply targets the
current inbound event and uses genuine reply semantics, including
after an earlier bot reply in that thread.

Live Matrix request/reply waits cover real HTTP sends, text batching,
and the first gateway turn. A cold-service exchange produced a valid
reply after 21.981 seconds, beyond the previous 15-second bound.
Use the fixture's existing 60-second wait bound and keep the complete
thread, reply and model-context assertions.
The gateway anchors every Matrix final response on the triggering
event, so send() passed that event as reply_to for each chunk of a
split response. Every chunk became a genuine thread reply with
is_falling_back set to false, and Element rendered a quote of the
user's message above each chunk. Streamed responses had the same
problem: the stream consumer anchors each new chunk on the previous
chunk, and later messages in the turn on the triggering event again.
In auto-thread rooms the synthetic thread root is the triggering event
itself, so the first reply in every new thread quoted its own root.

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

Unit tests cover a split final response with reply_to, the buffered
and live streamed paths, and a reply anchored on the root. A live
Synapse test limits the gateway to 500-character messages and checks
that only the first chunk of a threaded answer quotes the question.
Before this change the second chunk also quoted it.

Live Matrix request/reply waits cover real HTTP sends, text batching,
and the first gateway turn. A cold-service exchange produced a valid
reply after 21.981 seconds, beyond the previous 15-second bound.
Use the fixture's existing 60-second wait bound and keep the complete
thread, reply and model-context assertions.
Outbound thread relations accepted an explicit fallback anchor under
two metadata keys, matrix_thread_fallback_event_id and
thread_fallback_event_id. Nothing sets the unprefixed key, so remove
it. The prefixed key remains because Matrix thread creation sets it to
anchor the first reply of a new thread on the new root.
A threaded response quotes its triggering event once, and the later
messages of that response are thread fallbacks. The tracker recognised
a continuation only when reply_to was the event that Hermes had most
recently replied to, or Hermes's own event while it was still the
latest event in the thread. Any other genuine reply or inbound post
replaced those values, so the rest of the response quoted its trigger
again.

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

Keep, for each thread, a bounded list of the recent events that Hermes
has given a genuine reply to and of the events that Hermes has sent. A
send whose reply_to is in either list is a continuation. The fallback
target is still the latest known event in the thread.
The agent could only see Matrix messages included in the current
turn or initial thread backfill. It could not ask for a recent room
message, a thread, or an event when it needed more context. A generic
network tool would also lack the receiving bot's crypto store and room
policy.

Bind the receiving adapter and its event loop to the gateway turn.
Add a Matrix-only toolset that reads through that adapter, checks the
current room and requester, and bounds event counts and bodies. Use
the adapter's sync token for room history and its crypto object for
encrypted events. Report missing decryption keys per event so readable
neighbours remain available.

The focused tests cover the owning loop, room and user gates, thread
filtering, encrypted reads, and toolset selection.

Co-authored-by: Chris <16943149+nepenth@users.noreply.github.com>
Maintenance-adaptation: Room membership and room policy can change while
 the direct-room lookup awaits. Factor the existing room-policy
 condition into a synchronous predicate for admission and the post-await
 membership and room-policy recheck, before requester validation. The
 original inline adapter doubles bind the real predicate.
 Event-controlled membership, room-policy and requester cases reproduce
 stale-policy acceptance and verify refusal before client access.
@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) P3 Low — cosmetic, nice to have labels Oct 6, 2026
@iainlane
iainlane force-pushed the codex/original377-context-extraction branch 2 times, most recently from 4292baa to e1c8aa0 Compare October 8, 2026 13:43
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.

Current published supplier fixture compatibility

The current read-tools supplier has the same fake HTTP API session and
reconnect lifetime defects. Preserve the complete existing test module
and every assertion. Reuse the already published client factory from the
final credited maintenance scope so each fake Client exposes the actual
API session. Only the fourteen existing fake Client assignments are
adapted, and the canonical-ID reconnect fixture disconnects its first
connection before opening the second; production and the retained
eighty-five contributor records remain unchanged.
Matrix room discovery and command presentation need changes to room
identity reads and formatted HTML. Both topics were defined in the
3,536-line adapter facade, which also manages transport and delivery.

Move the existing identity and room policy methods into the existing
context mixin and move formatted HTML helpers into a rendering sibling.
Keep the adapter MRO and method bodies unchanged, and update the timeout
fixture and identity imports to their defining modules.

The original adapter contracts pass with the actual SDK. Executable AST
comparison is unchanged, and all 15 existing type diagnostics have the
same defining function, rule and complete message after the move.
@iainlane
iainlane force-pushed the codex/original377-context-extraction branch from e1c8aa0 to 7ec84d5 Compare October 10, 2026 07:07

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

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants