Skip to content

fix(matrix): preserve pending rich input and defer native batch checkpoints - #130877

Draft
iainlane wants to merge 346 commits into
NousResearch:mainfrom
iainlane:codex/matrix-native-pending-durability
Draft

iainlane wants to merge 346 commits into
NousResearch:mainfrom
iainlane:codex/matrix-native-pending-durability

Conversation

@iainlane

@iainlane iainlane commented Oct 1, 2026 •

Copy link
Copy Markdown

Stack

All PRs below target main. Nesting records merge order. Each dependent follows every prerequisite shown; the linked descriptions list their earlier dependencies.

Change

Matrix emote and sticker contributions retain their typed pending snapshot data. Native batch receipts defer checkpoint consumption until admission succeeds. The same contribution identities support later sender withdrawal. The pending native snapshot fixtures close their real session stores after the original assertions.

Current supplier and compatibility

The previous branch inherited four concrete conflicts against the fixed upstream revision 3637c512fc3211bdccb61b7140d09ac1018508ba. The independently published #128687 supplier at 1005c8fe8934609b57d149b5fee40b1426551fa6 resolves those conflicts and includes the three credited worker-lifetime prerequisites from #135482. All seven original Matrix contributions replay automatically on that supplier. The image-pack implementation from #126437 is already present: its four complete defining modules and complete adapter entry point match the published source, and its configuration remains available alongside the later Matrix toolsets.

The inherited live fixture still used a shared release marker across two phases, removed an expected-event file while the gateway could read it, and used the default interrupt policy for input that the test expected to queue. I applied the independently published #126375 fixture corrections at the original fixture contribution. Queued preparation has separate start and release paths, the expected value is reset atomically, and the withdrawal barrier records actual redacted event IDs. The complete queued sticker expectation also includes the existing source permalink. Every existing model, pixel and prior-history assertion remains.

The branch contains 346 credited records. The first 162 signed records are unchanged. The 184 affected records are rebuilt with the original author and committer headers, dates, timezones and order. Every original message remains verbatim, with fixture explanations appended only at the two introducing contributions. All 40 unchanged-tree contributions remain separate. The production implementation is the seven original contributions on the published supplier; the additional changes affect only three native fixture files.

Review the seven original contributions. The inherited fixture corrections precede that comparison and are described above.

Validation

The exact final source passes 496 cases across nine complete canonical SDK test files, with four strict warning categories and zero retries. Eight complete files contain 411 cases and finish without unclosed resources. The byte-identical rich-content SDK file passes 85 cases but emits the same 82 database shutdown warnings as the original public source; those cases remain qualified. Seven pending-shutdown warnings disappear with the published supplier correction. Full child output includes interpreter exit, and all actual repository inputs are authenticated before and after execution.

The complete live rich-content file passes its plain and encrypted scenarios against a disposable Synapse and separate Matrix clients. Both cases finish without unclosed resources. They exercise emote attribution, sticker pixels, paused context preparation, sender withdrawal, coalesced input, reactions, explicit room reads and stable prior model history. The gateway image is built with the project's package manager from the exact final source; the actual repository inputs and complete child output are authenticated.

The paired 15-path type comparison has 241 findings on the original source and 246 on both the published supplier and final candidate. Every candidate finding matches a baseline's full lexical span, complete statement AST, relative offsets and multiplicity. No new finding is introduced. Ruff passes; the same 14 files retain inherited formatting differences. The final local join is clean against the fixed upstream revision above.

These are local source and execution results. Hosted CI execution and maintainer acceptance remain pending.

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.

Preserve the Matrix and Discord source-note handling when integrating
the upstream prefix removal change. Use removeprefix after selecting
the platform-specific note, so each transport removes only its own
generated prefix.
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.
The adapter could post its own processing reactions, but a standard
Matrix agent could not request one. The existing send_message reaction
path was outside the Matrix session's model toolset.

Register matrix_reaction in the Matrix bundle and route react and
unreact through the session's receiving adapter on its gateway loop.
Use the inbound event ID from the turn as the default target so another
turn in the same room cannot change which message receives a reaction.

Track every annotation that the agent adds to a target. Unreact removes
those annotations without touching processing tapbacks, and a failed
redaction keeps its event ID available for a later retry.

A separate Synapse client verifies that the model discovers the tool
and that react and unreact create native annotation and redaction
events for the intended message.
Two unreact calls could read the same annotation list before either
redaction completed. Both sent a redaction, then the second call raised
ValueError when the first call removed the shared event ID.

Claim the tracked IDs before awaiting the server. Restore IDs that
fail to redact, including IDs still pending when the call is
cancelled. A concurrent call no longer redacts the same annotation.

The generic send_message reaction action also ran adapter coroutines
on a worker event loop. Matrix's live client belongs to the gateway
loop, so dispatch that action through the existing gateway-loop path
used for adapter sends.
`add_reaction` and `remove_reaction` fell back to the most recent event
that had started processing anywhere in the room when the caller gave no
`message_id`. Matrix threads run as separate concurrent sessions, so
that event could belong to another thread or another user, and the
agent would react to or unreact from the wrong message. The
`matrix_reaction` tool always supplies an event ID, and no in-tree
caller of `send_message(action="react")` relies on the fallback.

Remove the per-room record of the latest inbound event and return an
error when `message_id` is missing. `on_processing_start` goes back to
its original shape. The error strings with em dashes from the salvaged
commit go with the fallback.
`remove_reaction` decided success by reading `_agent_reactions` after
its redactions had finished. That map is shared, so when an
`add_reaction` on the same event recorded a new annotation while the
redaction was in flight, the unreact returned `success: False` even
though every annotation that it had claimed was redacted. When a
redaction did fail, the result had no `error`, so the model received no
reason.

Decide the result from the list of annotations that this call failed to
redact, and return an error message when that list is not empty.
`_agent_reactions` gained an entry for every event that the agent
reacted to and lost it only when the agent removed the reaction. A
long-running gateway therefore kept the ID of every reaction that it
had ever sent.

Store the records with `bounded_put`, as the adapters' other per-chat
maps do, capped at 1000 events. A new reaction or a failed redaction
moves the event to the newest position, so the eviction drops the
events that the agent has not touched for longest. Unreact already
works only for reactions recorded in this process, and an evicted event
falls under the same limit.

`bounded_put` was annotated for string keys only. Its implementation
accepts any hashable key, so type it generically for the tuple keys
used here.
`matrix_read` passes the session requester to the adapter, which checks
that the room is joined and admitted by `MATRIX_ALLOWED_ROOMS` and that
the requester is still authorised before it touches the client.
`matrix_reaction` did neither. Its only policy check was the admission
check that ran when the inbound message started the turn. If an
operator revoked a user's pairing during that turn, `matrix_read`
refused the user's later calls, but `matrix_reaction` still sent and
redacted annotations as the bot. A session without a user ID was also
refused by one tool and accepted by the other.

Move the room and requester checks from `read_matrix_context` into
`check_session_access`, and expose it on the adapter. The reaction tool
now requires `HERMES_SESSION_USER_ID` and runs that check on the
gateway loop immediately before it sends or redacts. Both Matrix
session tools therefore apply the same policy when they act.
After 30 seconds `matrix_reaction` cancelled the future for the
reaction, which cancelled `add_reaction` on the gateway loop. When the
homeserver had already accepted the `m.reaction` event, the
cancellation did not withdraw it, but `add_reaction` never reached the
line that records the reaction's event ID. The reaction stayed visible,
and a later unreact reported that no reaction was recorded.

Wait on the dispatched future through `asyncio.shield`, as the generic
send path in `send_message_tool.py` does, and stop cancelling it. The
timeout now ends only the tool's wait. The reaction keeps running on
the gateway loop and records its event when the send completes. The
timeout message tells the model that the reaction may still complete.

The test sets the timeout to zero and blocks the send on an event, so it
exercises the timeout without waiting on a clock.
The reaction tool test used `AsyncMock` adapter methods, which complete
on whichever event loop awaits them. If the tool awaited the adapter on
the worker thread's loop instead of dispatching to the gateway loop,
the test still passed, although the real Matrix client is bound to the
gateway loop.

Replace the mocks with a fake adapter that records, for each call,
whether it ran on the loop bound to the session. The test compares the
whole call list, so a call on the wrong loop fails it. With the
dispatch removed from the tool, all four calls record `False`.
The Matrix guide said that the agent can remove its own reactions from
a message. The adapter records agent reactions only in memory and keeps
records for the 1,000 most recently used messages, so after a gateway
restart, or for an older message, unreact finds no record and reports
an error.

State that limit, and state that each reaction or read call checks the
room allowlist and the requester's authorisation again.
The read-tools parent changed set_session_vars so that it no longer
records the running event loop as the session's transport loop. The
gateway passes its own loop as transport_loop, and other callers bind
no loop.

The reaction tool tests bound only the adapter, so the tool found no
loop and returned "Matrix gateway loop is unavailable" before reaching
the adapter. Bind the test's running loop as transport_loop, as the
gateway does for a live turn.
When the matrix_reaction handler ran on the session's gateway loop
itself, it awaited the adapter call directly and never applied
_REACTION_TIMEOUT_SECONDS, so a stalled send blocked the tool call
indefinitely. The tool also scheduled its coroutine onto the gateway
loop with its own code for closing the coroutine when scheduling
failed.

The read-tools parent fixed the same two problems in matrix_read.
Schedule the reaction with safe_schedule_threadsafe, which closes the
coroutine on every failure path, and await every call through the
same shielded deadline. A reaction that outlives the deadline still
completes on the gateway loop and is recorded for a later unreact.

The timeout test now also runs the handler on the gateway loop. Before
this change that case waited for the stalled send until the test's own
2-second guard failed it.
The `matrix_reaction` toolset is in the `hermes-matrix` composite but
not in `CONFIGURABLE_TOOLSETS`. The Matrix checklist of `hermes tools`
did not list it, and `hermes tools disable matrix_reaction --platform
matrix` printed "Unknown toolset" and saved nothing. Tool resolution
treated the non-configurable toolset as native to Matrix and added it
to every Matrix toolset list, including a saved list of individual
toolsets.

Add `matrix_reaction` to `CONFIGURABLE_TOOLSETS` and restrict it to
Matrix in `_TOOLSET_PLATFORM_RESTRICTIONS`, as the read-tools parent
did for `matrix_read`. The checklist and the enable and disable
commands now offer it on Matrix, and other platforms reject it. An
unsaved Matrix configuration and a saved `[hermes-matrix]` list enable
it through the composite. A saved list of individual toolsets enables
it only when the list includes `matrix_reaction`.

The parent's two toolset tests now run for both Matrix toolsets. The
toolsets reference lists `matrix_reaction` and the `hermes-matrix`
composite, and the Matrix guide describes turning either toolset off.
An earlier commit on this branch changed send_message's react and
unreact actions to run the adapter call on the gateway loop, so that
the Matrix adapter's add_reaction and remove_reaction would use the
live client from its own loop. That path is not reachable by the
model. send_message has not been a registered model tool since
c6c8abb (NousResearch#47856), and the only in-tree callers of send_message_tool,
`hermes send` and the MCP server's messages_send, always pass
action="send". A process outside the gateway also has no live adapter,
so it gets the "Reactions require a live adapter" error.

The matrix_reaction tool is the model's route to Matrix reactions, and
it applies the room and requester checks on the gateway loop. Restore
send_message_tool.py and its react test to the versions on main
instead of maintaining a second Matrix dispatch without those checks.
A relay-fronted conversation can retain Matrix as its session platform,
but its delivery adapter has no native Matrix session-access method.
The reaction tool accepted that adapter and failed with AttributeError
when it tried to check room policy. The read tool already checks its
native adapter capability before scheduling work.

Require the reaction access-check capability at the same boundary.
Relay-fronted input now receives the live-Matrix-session error without
scheduling an unsupported operation. Native Matrix actions keep their
room and requester policy checks on the owning gateway loop.

The relay contract fails on the preceding head. The missing-adapter
control and repaired relay case pass with the affected six-file gate:
299 tests passed, six skipped and no retries.
The declared filename tests do not show whether media from a separate
Matrix client reaches the model after the gateway downloads and caches
it. A filename-like caption also needs to remain distinct from the
actual document filename across that path.

Upload a document to disposable Synapse with different caption and
filename values. Check the model-visible context, the gateway reply,
and the exact bytes cached under the declared filename.
The gateway removes an addressed bot mention before it builds the
inbound message. If a caption then matches the declared filename, the
media normaliser mistakes it for a client-generated filename and drops
the user's text.

Compare the original Matrix body with the filename to decide whether
the body is a transport fallback. Return the cleaned body for model
context when the original body contains a caption. A parameterised
regression covers a mentioned caption that matches the filename after
mention removal.
The live caption test requested a one-second Synapse poll and used
an outer loop deadline of one minute. Matrix-nio can retry a sync
request beyond that deadline, so a stalled exchange could run for
much longer than the diagnostic claimed.

Request a 250 ms server wait and bound each sync by the remaining
15-second reply deadline. The test still observes the reply through
the independent Matrix client and checks the model request and cache.
A Matrix client that replies with media and no caption can put the
inline reply fallback before the filename in body, for example
"> <@erin:example.org> nice photo\n\nphoto.png". The caption decision
compares the original wire body with the declared filename, or applies
the filename heuristic when no filename is declared. The wire body
still contains the fallback, so neither check recognised the bare
filename, and the model received "photo.png" as the user's text.

Strip the reply fallback from the wire body before the comparison when
the event replies to another event. Mentions stay in the wire body, so
a caption that only matches the filename after mention removal is still
kept. A parameterised regression covers the reply with and without a
declared filename.
test_other_client_media_event_keeps_declared_filename_and_caption sent
an m.file event with the caption "notes.pdf" and the filename
"original.pdf" through _on_room_message. The legacy heuristic never
drops a PDF name on m.file, the fake download returns no bytes so no
cache file is named, and raw_message equals the event content by
construction. The test therefore passed on the adapter before MSC2530
support.

The m.file case of
test_declared_filename_preserves_filename_looking_caption_and_names_cache
already checks that the document cache receives the declared filename
while the caption is kept, and it fails on that adapter.
The live media test sent an m.file with the caption "screenshot.png".
For m.file, the legacy heuristic only treats audio and video names as
bare filenames, so the adapter before MSC2530 support also forwarded
that caption. The test failed on that adapter only because of the cache
filename, and its caption check could not detect a lost caption.

Use "clip.mp4" as the caption, which the legacy heuristic drops for
m.file. Check that the paragraph after the document note is exactly the
caption, before the cache checks, so a dropped caption fails on its own.
The document note must also give the declared filename and the cached
path. The model context previously went through json.dumps and a
substring search, which the filename in the note could satisfy.
The concurrent-update fixture creates three handler coroutines before
passing each to the processor. Cancelling the middle update while it
waits for its predecessor leaves that handler coroutine unawaited, so
the fixture fails under strict RuntimeWarning checks.

Keep references to the fixture-created coroutines and close the
cancelled handler after every processor task has finished. The existing
assertions still require the first and third handlers to complete in
order without executing the cancelled handler.
Direct FIFO callers such as /queue, /steer fallback and Telegram grace
could bypass the 32-entry bound. Normal busy admission only logged a
refusal, while /queue acknowledged input even when no event was queued.
Debounced text was also outside the pending count, so a later photo or
command could overtake an older accepted burst.

Check admission at the shared FIFO boundary and return its result to
command and busy handlers. Count buffered bursts as pending input and
submit older bursts before accepting a later FIFO event. Refused
external input receives a thread-aware queue-full reply; internal wakes
remain unaccepted so their existing producers can retry. Earlier
accepted events are never replaced to admit the new input.

Copy command events without reconstructing their fields. This preserves
reply, media and control context together with the existing bot-loop
admission flag, so replay cannot acquire permissions or spend admission
twice after stripping the command prefix.

Parameterised contracts reproduce bypassed capacity, false
acknowledgements, missing refusal notices, context loss and overtaking
of buffered input.

fix(i18n): translate the pending queue refusal

The bounded pending queue reports an English-only warning when admission
is refused. The other sixteen bundled language catalogues omit that key,
so users receive the English fallback even after selecting their language.

Add the refusal message to every bundled catalogue. Verify that runtime
lookup selects the requested catalogue, that non-English results differ
from the English fallback, and that all catalogue keys and placeholders
remain in parity.

The runtime contract failed for all sixteen translated languages before
the entries were added. The complete four-file locale gate now passes
all 92 cases.

Source-commit: cd37cd3a25ab9c157495df61ed3a8049765e90ab
Protected-source-alias: b01a5942e1b077ee7dcd7ad73f3cc0002f926ae1
Maintenance-adaptation: Apply the debounce admission method change in
 base_text_debounce.py, preserving its typed receiver. Add the original
 refusal-control branch to the current platform handler without changing
 the upstream selected-owner loop. Keep the predicate name and defining
 merge import from the earlier sources in the busy-session module; its
 other incoming method and declaration bytes remain unchanged.
Maintenance-adaptation: Declare the existing pending-refusal
 collaborator interface and use pytest monkeypatch for the admission
 fixture overrides and dynamic marker; keep the original scenario values
 and assertions.

Captured upstream compatibility for bounded pending admission

Keep the current unquoted MessageEvent annotations in the FIFO and busy
steering methods. Preserve the original boolean return values and
admission behaviour, including refusal reporting and queued input.
@iainlane
iainlane force-pushed the codex/matrix-native-pending-durability branch from e08f74e to a2eba03 Compare October 6, 2026 09:47
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 original feature tests predate the canonical approval lifecycle,
permalink source fields, room policy owner argument and SDK send error
classification. Their doubles omitted asynchronous state-store reads,
exact approval request IDs and terminal card edits. Several complete
expected events also omitted the newly composed source and mention
metadata, so the full feature gate failed before checking its intended
behaviour.

Update those fixture bindings and complete expected values for the
current defining owners. Keep every original parameter row, delivery
attempt, payload, cancellation and authorization assertion. Thread
reconnect expectations retain confirmed delivery and cache identity.
Catch-up expectations include the permalink note before the new message.

The complete nine-file feature boundary passes all 808 cases with the
actual installed Matrix SDK, four workers and no file retries. The full
Matrix file finishes in 30.7 seconds, including the event-loop lookup
contract that timed out on the earlier incomplete source composition.

Original858 public source qualification

The preceding 808-case result records the original composition. It does
not describe a new run against this public tree. The published backfill
composition has 343 scoped cases, using 264 final affected cases and 79
unchanged earlier successes. Its inherited formatter and type findings
remain qualified, and live Docker validation remains pending.

The other feature contracts are represented by their existing public
owners. Their fixture expectations follow the actual approval, notice,
location, send-classification, read-policy and delivered-event APIs.
Expectations from the original later composition do not establish bugs
in standalone owners that use an earlier API. No combined six-module
endpoint or repeated native gate is required for this source credit.

This source-neutral contribution preserves the complete original
message and both original contributor dates. It records the distributed
source qualification without changing the published source tree.

Current linear successor qualification

The 343-case result above describes the earlier a76019d composition.
This contribution follows the verified single-parent successor a83881c
and keeps its source tree unchanged. The accepted seven-file gateway
gate for that successor passes 362 cases. Live Matrix validation,
private integration and whole-campaign completion remain separate.
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.
The dependency-rebinding fixtures installed a fake importer but left the
package-availability check unchanged. With a complete SDK installed,
ensure_matrix_deps skips the importer, so these fixtures failed without
exercising their intended success and refusal paths.

Report a missing Matrix package in the fixture alongside the existing
installation seam. The real rebind and failed-import assertions then
exercise the intended fresh-install boundary.
The Matrix adapter ignores emotes and does not register native sticker
events. Emotes describe the sender's action, while stickers use a
separate event type with a description and an image. The existing text
handler would interpret a slash-like action body as a gateway command,
so native actions need attributed text before command classification.

Accept both through the existing room, sender and mention gates. Prefix
emotes with their actor, preserve sticker descriptions, and download
sticker pixels through the bounded media path. Use the same labels in
effective reads, replies and thread history.

Queued stickers can be edited or redacted while media preparation is
pending. Add an optional media-event snapshot capability and retain the
source paths of image descriptions, so final model preparation removes
both withdrawn pixels and their derived text. Existing platform
snapshots remain compatible and previous conversation context remains
unchanged.

The two parameterised invariants reproduce six failures on the committed
parents and now cover native SDK events, gates, unavailable media and
withdrawal during asynchronous preparation. Matrix and adjacent gateway
checks pass against the pinned SDK source.

The live test removes a shared expected-event marker between phases,
uses the default interrupt policy for queued input, and reuses the first
phase release path. The separate gateway can observe stale mounted
markers or fail between a marker existence check and its read. Reset the
expected value atomically, configure queue mode through the existing
fixture, and give queued preparation separate start and release paths.
Record actual redacted cache entries by event ID so the second
withdrawal barrier acknowledges its exact target. Preserve every model,
pixel and prior-history assertion.

Apply the independently published native fixture corrections from PR
NousResearch#126375 at 6f027ce.
@iainlane
iainlane force-pushed the codex/matrix-native-pending-durability branch 5 times, most recently from 9eef216 to e2d2c6b Compare October 9, 2026 00:41
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.
Pending-event merges and busy-text debounce share reply and attachment
semantics inside the large base adapter. Telegram's ingress overrides
also modify the same batching boundary. Extract those existing topics
before integrating the reviewed reply-context fixes.

Repoint in-tree imports and patch targets to the defining merge module.
The adapter classes inherit their existing methods, so timers, tracked
redispatch and delayed-delivery ownership keep their current behaviour.

The fixed upstream annotation cleanup overlaps the extracted debounce
state. Keep the state in its existing topical module and apply the same
unquoted task annotation there. The complete state and timer behaviour
remain unchanged.
Cached auxiliary-client teardown is mixed into the large routing module.
The worker-loop lifetime correction needs to await only the transports
owned by a terminating worker, without expanding that routing module.

Move the three transport-close functions into a lifecycle sibling and
import the close function at its existing callers. Their full function
ASTs and the remaining routing-module statements are unchanged.
Tool dispatch keeps one event loop per executor thread so cached async
clients can reuse their transports between calls. The thread-local
cache stored only the loop, so ending an executor left the loop and its
selector sockets open. Auxiliary clients also remained cached after
their owner thread exited. Temporary workers used inside an already
running event loop also closed their loops before finishing generators
and cached transports.

Give each worker a Runner owner with a finalizer attached to its
thread-local lifetime. Cancel remaining tasks and finish generators
before closing cached clients on that loop. Wait for their close tasks,
then let Runner shut down the executor and close the loop. Clients that
belong to other live loops remain cached and open.

The regression uses actual executor threads and SDK HTTP connections to
a local server. It checks reuse between calls, generator cleanup before
client closure, closed worker resources, and an unaffected main-thread
client in both worker modes. Timeout cancellation still runs inside the
worker loop, and executor shutdown remains nonblocking for the caller.
The existing fake timeout executor now also closes the test
coroutine which it deliberately never submits.
@iainlane
iainlane force-pushed the codex/matrix-native-pending-durability branch from e2d2c6b to 5b1b1b0 Compare October 9, 2026 12:25
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 9, 2026
A queued sticker or emote includes a MatrixRichContentSnapshot. Shutdown
capture asks every inbound snapshot for data-only pending state, but the
native-content implementation did not provide that method. Both native
inputs therefore failed snapshot capture and could not be preserved.

Record the nested turn context and the original event, text, attachment
identity and attachment paths for each contribution. Live adapter and
cache objects remain process-local. The saved identity lets restoration
compare each attachment with current Matrix state before model use.

The regression uses actual typed Matrix emotes and stickers through the
adapter and shutdown writer. Both capture paths now succeed, and the
same live dependency still withdraws text and media after redaction.

The rich pending snapshot caller creates a real store through its runner
helper. Apply the published NousResearch#130877 finally cleanup after its complete
original exercise, preserving the private restored-event decoding and
live revalidation assertions.
Riccardo-Vecchi and others added 2 commits October 9, 2026 17:33
Exercise the real async bridge with a background task and a native default-executor thread in persistent and running-loop modes. Complement PR NousResearch#135482 transport and async-generator coverage without replacing its implementation.

Prepared with OpenAI Codex assistance; no human review claimed.

Use the default-executor shutdown API supported by Python 3.11, which
is the minimum version for this project. The test releases its own
executor work before shutdown and verifies that the worker thread exits.
Pending withdrawal changes three oversized modules: the shared adapter,
turn execution and Matrix adapter. Extract their existing pending merge,
post-turn selection and redaction handling into topical siblings first.

The Matrix redaction and runner drain methods keep their complete bodies.
The pending merge keeps its existing caption helper through a local
adapter import. In-tree callers and patch targets use the defining
module, with no compatibility re-export. The strict fifteen-file gate
passes 631 tests before any withdrawal behaviour changes.
@iainlane
iainlane force-pushed the codex/matrix-native-pending-durability branch from 5b1b1b0 to f3e231b Compare October 9, 2026 16:34
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 9, 2026
A queued sticker or emote includes a MatrixRichContentSnapshot. Shutdown
capture asks every inbound snapshot for data-only pending state, but the
native-content implementation did not provide that method. Both native
inputs therefore failed snapshot capture and could not be preserved.

Record the nested turn context and the original event, text, attachment
identity and attachment paths for each contribution. Live adapter and
cache objects remain process-local. The saved identity lets restoration
compare each attachment with current Matrix state before model use.

The regression uses actual typed Matrix emotes and stickers through the
adapter and shutdown writer. Both capture paths now succeed, and the
same live dependency still withdraws text and media after redaction.

The rich pending snapshot caller creates a real store through its runner
helper. Apply the published NousResearch#130877 finally cleanup after its complete
original exercise, preserving the private restored-event decoding and
live revalidation assertions.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 9, 2026
A queued sticker or emote includes a MatrixRichContentSnapshot. Shutdown
capture asks every inbound snapshot for data-only pending state, but the
native-content implementation did not provide that method. Both native
inputs therefore failed snapshot capture and could not be preserved.

Record the nested turn context and the original event, text, attachment
identity and attachment paths for each contribution. Live adapter and
cache objects remain process-local. The saved identity lets restoration
compare each attachment with current Matrix state before model use.

The regression uses actual typed Matrix emotes and stickers through the
adapter and shutdown writer. Both capture paths now succeed, and the
same live dependency still withdraws text and media after redaction.

The rich pending snapshot caller creates a real store through its runner
helper. Apply the published NousResearch#130877 finally cleanup after its complete
original exercise, preserving the private restored-event decoding and
live revalidation assertions.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A queued sticker or emote includes a MatrixRichContentSnapshot. Shutdown
capture asks every inbound snapshot for data-only pending state, but the
native-content implementation did not provide that method. Both native
inputs therefore failed snapshot capture and could not be preserved.

Record the nested turn context and the original event, text, attachment
identity and attachment paths for each contribution. Live adapter and
cache objects remain process-local. The saved identity lets restoration
compare each attachment with current Matrix state before model use.

The regression uses actual typed Matrix emotes and stickers through the
adapter and shutdown writer. Both capture paths now succeed, and the
same live dependency still withdraws text and media after redaction.

The rich pending snapshot caller creates a real store through its runner
helper. Apply the published NousResearch#130877 finally cleanup after its complete
original exercise, preserving the private restored-event decoding and
live revalidation assertions.
@iainlane
iainlane force-pushed the codex/matrix-native-pending-durability branch from f3e231b to dfc8a8a Compare October 10, 2026 07:15
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A queued head leaves pending storage before its handler finishes
admission. A concurrent arrival could fill the vacated slot, then a
deferred dispatch restored the accepted head and increased the queue
beyond its bound. Cancellation during that same window could start a
successor from the old task's cleanup before the caller restored the
unclaimed head.

Reserve a removed FIFO head while dispatch is provisional. Count the
reservation only while the event is absent from the queue and buffered
storage. Release it when the runner claims a real turn, restores the
head, or completes the owning task. Rewritten and ID-less events use the
existing task-scoped dispatch identity; cleanup cannot remove a
replacement head's reservation.

Expected cancellation leaves successor dispatch to the cancelling
caller, including when an admission callback suppresses cancellation and
completes. Record successful claim on the reservation so cancellation
can check that state after awaiting the task. When pending input is
retained, restore only an unclaimed head before its successors. Once a
turn has claimed the event, cancellation cannot replay that input. The
caller's discard and command-guard options still decide whether pending
work resumes.

The concurrent-arrival contract reproduces a 33rd pending event before
the reservation. Lifecycle contracts verify cancellation before callback
entry, during admission and after claim, including an admission callback
that suppresses cancellation before claim. They also verify complete
media/reply/control context.

Maintenance adaptation: import the reservation helpers from their
defining pending module in the platform facade. The debounce predicate
wrapper remains in its existing sibling, so the facade does not import
that predicate. Preserve the earlier predicate name and defining merge
import in the busy-session module when applying the original reservation
methods. The upstream handler and selected-owner loop remain unchanged.

Maintenance-adaptation: Declare the existing session-state and optional
 delivery-adapter interfaces. Keep the reflective optional-method
 guards, make their None narrowing explicit, and use dynamic attribute
 lookup for both guarded calls without changing call arguments or
 admission and reservation implementations.

A sentinel claim can precede the durable user row. Keep queued input
recoverable during admission and preparation, and bind its prepared
session and input owner to the captured dispatch. Check that exact owner
in the source profile before omitting the reservation from recovery or
queue capacity. Definite plugin consumption, lease refusal and normal
handler completion still release the reservation.

Preserve provisional input when a cancellation waiter times out. The
task may finish later without a durable transcript owner. Reuse the
accepted public cancellation completion correction: retain its
reservation until the completion callback reconciles recovery once,
while durable inputs remain excluded. Existing contributor source,
identities and credit records remain unchanged outside these dispatch
and regression scopes.

Current compatibility on captured main:

Keep the actual published NousResearch#130877 prerequisite and its complete current
source. The first 57 copied records remain explicit contributor credits;
their effects are supplied by that prerequisite. Preserve its current
annotations, bounded recovery, fixture cleanup and live-test corrections,
then replay the four attachment filename records at their original scope.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 11, 2026
A queued sticker or emote includes a MatrixRichContentSnapshot. Shutdown
capture asks every inbound snapshot for data-only pending state, but the
native-content implementation did not provide that method. Both native
inputs therefore failed snapshot capture and could not be preserved.

Record the nested turn context and the original event, text, attachment
identity and attachment paths for each contribution. Live adapter and
cache objects remain process-local. The saved identity lets restoration
compare each attachment with current Matrix state before model use.

The regression uses actual typed Matrix emotes and stickers through the
adapter and shutdown writer. Both capture paths now succeed, and the
same live dependency still withdraws text and media after redaction.

The rich pending snapshot caller creates a real store through its runner
helper. Apply the published NousResearch#130877 finally cleanup after its complete
original exercise, preserving the private restored-event decoding and
live revalidation assertions.

This branch has not been deployed

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

Labels

area/compression Context compression and continuation sessions area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants