Skip to content

feat(matrix): add requester-scoped reaction menus - #126349

Draft
iainlane wants to merge 546 commits into
NousResearch:mainfrom
iainlane:feat/matrix-reaction-menus
Draft

iainlane wants to merge 546 commits into
NousResearch:mainfrom
iainlane:feat/matrix-reaction-menus

Conversation

@iainlane

@iainlane iainlane commented Sep 28, 2026 •

Copy link
Copy Markdown

Stack

All PRs shown target main. Nesting records merge order: each parent must merge before its child. The commit history uses #125849, which includes the approval lifecycle from #126288. The other prerequisites specify logical merge order. Each prerequisite description lists its earlier dependencies.

Summary

Add optional reaction menus for Matrix, scoped to the user who asked for them, through the existing Matrix choice-picker and turn lifecycles. The agent calls present_menu with a question and one to five choices, each with a distinct emoji, a visible label and a payload. The menu appears in the current room or thread, and the agent then finishes its current reply. A selection arrives as a new user turn with the original question, the context ID, the label and the payload. Menu text cannot invoke slash commands.

Only the requester can select a choice, and that person must still pass gateway authorization; a requester whose authorization was revoked receives a notice. The room, thread, runtime profile and conversation stay pinned. A choice follows its session through context compression, but a choice from a conversation that was reset with /new is refused with a notice. Selection consumes the control before the callback awaits, so a repeated or later choice cannot start a second turn. A reaction on an approval prompt or a model picker in the same room affects only that prompt.

Arguments and pending controls are bounded. Menus expire after five minutes. A reaction on an expired menu gets a reply, and Hermes redacts its own seeded reactions from expired and replaced menus. A new menu replaces the previous menu in the same session. Menus are kept in process memory and do not survive a gateway restart. There is no message-history index, no behavioral environment gate and no always-present core tool. Menu callbacks run on the gateway loop without secret hydration and without session-store lookups.

Behavior change for plugin message injection (all gateway platforms)

Menu choices and plugin message injection (ctx.inject_message(..., session_key=...)) are both strict internal events: each is pinned to the session ID that was current when it was queued. Both now use the helper that heartbeat admission already used, which also admits an event when the current session is the compression continuation of the pinned session.

For plugins this changes behavior on every gateway platform. Before this PR, an injection queued behind a turn that compressed the conversation was dropped silently when it was dequeued. It now runs in the session that compression created, in the same conversation. An injection is still dropped silently when topic recovery changes the route or when the conversation is reset with /new or /reset. The plugin guide says so.

Other platforms

  • The plugin injection change above.
  • reaction_menu is a new toolset containing present_menu. It is off by default and restricted to Matrix sessions, so other platforms never see the tool. hermes tools lists it as "Reaction Menus".
  • The gateway's TurnRunner sets a present_menu callback on every agent that it runs, and the inline tool executor table gains present_menu. The tool reaches the model only when the toolset is enabled for a Matrix session.

Configuration and documentation

  • Enable Reaction Menus in hermes tools, or explicitly select reaction_menu or hermes-matrix in platform_toolsets.matrix in config.yaml. An implicit default selection keeps reaction menus off. Other platforms never receive present_menu. Restart the gateway or start a new session after changing toolsets.
  • The Matrix user guide describes agent reaction menus, including expired, replaced and reset menus. The toolset and tool references list reaction_menu and present_menu. The plugin guide describes the new compression behavior of injected messages.

Review scope and verification

The current own contribution comparison isolates the twelve retained menu contributions and the expiry-notice repair from the published signed #125849 prerequisite. The previous own contribution comparison records their earlier published source.

All twelve original contributions retain their complete author, committer, dates and messages, including the source-neutral toolset attribution. The composition preserves the current prerequisite's complete approval lifecycle, reply modes and notice handling. Two bounded upstream joins add reaction_menu to the current default-off toolset set and add the original Matrix tool count to the current tool reference. The native fixture merges automatically with the current prerequisite.

The full shutdown output reproduced eight unclosed database emissions from the original menu and plugin compression/reset tests. Each test now registers cleanup for its SessionStore. These repairs are folded into the contributions that introduced the fixtures, with every original behavioural assertion preserved.

I verified 195 cases across eleven complete files through scripts/run_tests.sh, using the actual SDK, four workers, strict resource/runtime/thread/unraisable warnings and no retries or skips. This result combines the original eleven-file invocation with a fresh run of the two repaired files, which pass all 41 cases. The other nine files retain their complete tested source. Seven complete files, containing 111 cases, have clean resource shutdown. Four complete files, containing 84 cases, retain eight database shutdown emissions from prerequisite fixtures; a separate run of all four complete prerequisite files reproduces the same eight emissions. This is qualified validation, not a resource-clean claim for all eleven files.

I also ran both original cases in the complete live menu module against a fresh Synapse and Linux gateway on the current PM image. The plain-thread case verifies requester refusal, one-shot selection, original-thread routing, preserved model prefix and role alternation. The encrypted case verifies delivered menu text and the selected payload. Both cases pass with clean resource shutdown. All 8,552 tracked Python and runner inputs are recorded against the executed provisional head 83284315; package and lock inputs are also bound in the image proof.

Ruff reports no findings across all nineteen changed Python files, and committed whitespace checks pass. The candidate has 162 type diagnostics, compared with 173 on the previous menu source and 165 on the current prerequisite. Every complete lexical span, column, enclosing statement, relative occurrence and multiplicity matches one of those sources. All eighteen full-file formatter differences are inherited. This is a differential check, not a clean type or formatter result.

The source merges cleanly with the fixed compatibility target 3637c512. Independent review accepted the complete source joins, both fixture repairs, literal history and the retained empty contribution. Validation executed the provisional composition; rebinding its parents to the published signed prerequisite preserves every complete commit tree. These checks do not establish hosted CI acceptance or validation of the entire inherited stack.

Expiry-notice repair

An expiry notice now stays in the thread that contains the menu, including when another output message arrives before the expired reaction. Picker state retains a copy of its send metadata and uses the existing threaded-control projection from #128710. The expiry path forwards that metadata to the existing feedback sender. The notice replies to the expired card; when rich replies are disabled, its thread fallback still targets that card. Timeline menus retain their existing reply behaviour. Expiry still removes the control and never invokes the selected callback.

The new regression uses the actual Matrix adapter and installed SDK event with a controlled send transport. Both threaded variants fail on the preceding public head, while 26 cases pass. The repaired complete menu file passes all 28 cases with one worker, zero retries, and strict resource, runtime, thread and unraisable warnings. Complete child output through interpreter exit is resource-clean. All 9,362 tracked Python, configuration, lock and runner inputs are bound before and after this run. This is adapter-path validation; the historical live homeserver receipts above retain their original source qualifications.

Ruff passes across the three changed files. The full static comparison contains the same 13 inherited type diagnostic occurrences, with no new occurrence. All three files retain their existing full-file formatting differences; the new regression is formatted. The repair is one additive commit above 6bcc189dec46bdc057f631815c40f6fceb79d6d0.

Known limitations

  • A reaction on a menu that a newer menu replaced is ignored without a reply. The replaced menu no longer shows the bot's reactions.
  • If /new clears the queue while a choice waits behind the turn that presented the menu, the choice is discarded without a notice.
  • An expired menu that nobody reacts to keeps its seeded reactions until the next menu send removes them. Expiry is lazy, as it is for the model picker.
  • In the encrypted case, reactions are plain annotations, because matrix-nio and mautrix both send reactions unencrypted.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets platform/matrix Matrix adapter (E2EE) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation labels Sep 28, 2026
@iainlane
iainlane force-pushed the feat/matrix-reaction-menus branch from dda37f2 to bd75ec3 Compare October 1, 2026 09:36
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.
Matrix gateway admission and text-batch receipts share the durable sync
boundary. Move their unchanged methods into an intake mixin before
extending the same boundary to native content. The adapter retains its
configuration, event parsing and transport lifecycle.

The four method ASTs are unchanged apart from a defining import for the
adapter's command normalisation helper. The existing sync, native-content
and reaction admission contracts pass after extraction.
A native emote can wait in the ordinary text batch while Matrix processes
later sync responses. Its handler returned no receipt, so the durable
sync cursor advanced before the batch entered the gateway. A restart in
that window could lose the emote. Ordinary text already deferred the
cursor until the same batch completed admission.

Use that existing admission method for both text and native emotes. Each
buffered event gets the batch's deferred receipt, and immediate emotes
report the same consumption result as immediate text.

The regression uses typed mautrix sync events and the actual durable
checkpoint store. Both text and emotes keep the previous cursor while
buffered and advance only after gateway admission.
The restart fixture waited for the model task after admitting its prime
input. Durable batch admission commits the sync cursor in a separate
checkpoint task, so the fixture sometimes read the old cursor and failed
although the input had been admitted correctly.

Wait for the existing checkpoint receipt before checking the admitted
input and cursor. The assertions still require the same exact message
and completed checkpoint.
The extracted intake mixin calls the gateway's cooperative message
handler and uses Matrix caches, parked voices and admission receipts.
Those dependencies were implicit in the adapter, so the standalone
mixin could not type-check its calls or its super dispatch.

Make the existing BasePlatformAdapter relationship explicit and declare
the Matrix-specific attributes and callback signatures. The adapter's
runtime method resolution order is unchanged, including the gateway
handler that accepts each durable intake.
The native rich-content contract expected a redaction placeholder after
withdrawing one queued sticker. Pending input is removed before its turn
starts, so the shared withdrawal path rebuilds the merged input from the
remaining contribution. Placeholders apply to content revalidation after
a turn has already started.

Assert the complete retained caption, attachment echo and exact PNG for
the queued phase. The existing model-request count requires one remaining
turn, and the whole input excludes the withdrawn caption and image. Keep
the earlier active-turn placeholder contract unchanged.

A separate native admission observer confirmed that both stickers were
coalesced before the sender redacted the second contribution. The final
model input contained only the retained contribution; the obsolete
placeholder expectation failed in both plain and encrypted rooms.
The queue composition records the identity of each merged contribution
so a pending withdrawal can remove the correct input. The older busy
photo, startup and grace controls still expected the pre-withdrawal
MessageEvent shape, whose merged message identity list was empty.

Keep those whole-event contracts and include the incoming contribution
identity in the expected merged event. All sender, reply, permission,
attachment and FIFO boundaries remain asserted. The three compatible
controls fail on the published composition before this correction.
Input that arrives during startup restoration waits in a separate list.
The shutdown writer only captured adapter and overflow queues, so stopping
before the startup gate opened lost that deferred input.

Include the startup list in the existing profile-scoped snapshot writer.
Each session keeps its FIFO tail followed by its deferred inputs. Remove
only the inputs whose complete snapshot was written; a failed profile
write leaves its inputs available for retry.

The invariant uses two owning homes in A/B/A order through the real
startup deferral gate. Both homes get their complete ordered records, and
a controlled write failure preserves A's inputs while B succeeds.
Pending input can await the busy handler or recursive turn preparation
before the next model turn starts. Those paths are inside the oversized
adapter and turn execution modules, which makes their shared dispatch
ownership boundary difficult to change in a focused patch.

Move the unchanged methods into topical mixins before extending pending
withdrawal through those waits. Keep the existing facade bindings,
logger identities and lazy imports. The method ASTs are unchanged apart
from the defining import for the adapter's thread metadata helper.
Pending preparation can overlap cancellation, shutdown and a later input
while provider or handler work awaits. The processing and task ownership
methods remain inside the oversized base adapter, and the voice interrupt
helper remains inside the oversized turn module.

Move the unchanged adapter processing lifecycle into a topical mixin and
move the unchanged interrupt helper beside pending selection. Existing
callers still use the adapter and runner methods through their MRO. Keep
logger identities and defining helper imports, and declare the actual
host attributes and callback types before extending the ownership model.

The eleven method ASTs are unchanged except for the defining adapter
helper import. Existing ownership, shutdown, voice and follow-up contracts
remain green. The normalized type comparison adds no diagnostic.

The current prerequisite reconciles pending input after a cancellation
wait times out. Preserve that completion path when extracting processing
methods, including the original local imports for their facade helpers.

Resolve the captured upstream changes in the original defining scopes
when updating this contribution. Keep the extracted implementations and
authored behaviour; apply the same current annotation, timeout or UTC
spelling in their actual owners where those scopes moved.
The Matrix adapter cached every inbound audio, video and file
attachment under a name taken from the event's raw `body`. It never
read the `filename` field. Since Matrix 1.10, a client that sends a
caption puts the filename in `filename` and the caption in `body`, and
some clients send a caption in `body` without any `filename`. A reply
can also prefix the body with a quoted reply fallback. The cached
document was therefore named after the caption, for example
`doc_<id>_please review`, or after several lines of quoted text. Audio
took its extension from whatever followed the last dot in the body: a
reply whose fallback quoted `@bob:example.org` produced a file named
`audio_<id>.org> what did you think?` with the rest of the caption
after it. That name has no audio extension when the audio container
is not recognized.

Add `inbound_media_filename`, which returns the declared `filename`,
or the body with any reply fallback removed when the body is itself a
filename. A body counts as a filename when it is one line with no
directory part and ends in an extension, and either the extension
maps to a known MIME type or the body contains no spaces. A client
that follows the spec without captions, such as one that sends
`Q3 Report.pdf` as the body, keeps its name. A multi-line caption, or
one containing a URL, does not become a filename. The helper also
reduces a declared filename to its last component for both `/` and
`\`, so the name contains no path separator on any host.

When neither source gives a filename, the adapter caches the document
as `document` or `video` with the extension for the event's MIME type,
and audio takes its extension from the MIME type as well. The voice
flag from `_classify_inbound_media` was used only to choose between
two defaults that both ended in `.ogg`, so it is removed.

`cache_document_from_bytes` already strips directory components and
checks that the result stays inside the cache directory, so a sender
could not write outside it before this change either.

Integration: apply the cache-name change in the extracted media sibling.
Keep the canonical three-field media classification contract and remove
only the obsolete download filename default. Adapt the tests to streamed
SDK downloads and the current room identity model.

(cherry picked from commit fa87c83)
Root AGENTS.md asks for a facade's siblings to use the name
<stem>_<topic>.py. The filename helper belongs to adapter.py, so the
module becomes adapter_media.py, as in the Discord adapter.

Integration: the media sibling already contains the extracted handler.
Move the helper into that module with its function bodies unchanged.

(cherry picked from commit 2e0bbf2)
The first version of `inbound_media_filename` rejected any name that
`str.isprintable()` refused. That excludes every Unicode space other
than U+0020 and every format character. macOS names screen recordings
with U+202F before "AM" or "PM", and emoji sequences contain U+200D,
so such an attachment was cached as `video.mov` even when the client
declared the name in `filename`. A declared name that reduced to
nothing, such as `..`, also stopped the helper from using a clean
body, and a non-string `filename` was cached under its `str()` form.

A body without `filename` needed a MIME type known to `mimetypes`
whenever it contained a space. That table differs between Python
versions: `config file.yaml` was kept on 3.14 and dropped on 3.11 to
3.13, and `Contract Final.pages` was dropped on all of them. Python
3.14 also treats a trailing `.` as a suffix, so `Done.` became a
filename there only.

Remove only control characters (category Cc) and the bidirectional
embedding, override and isolate controls, which can make a name
display with a different extension. Use `filename` only when it is a
string and fall back to the body when it reduces to nothing. Accept a
body as a filename unless it has more than one line, contains a path
separator (which excludes a URL), or lacks an extension of one to ten
letters or digits. The rule uses no lookup table, so every supported
Python version gives the same answer.

Take the generic extension from `media_cache.ext_for_mime`, which
the other adapters share. It removes MIME parameters, so
`video/mp4; codecs=avc1` now gives `video.mp4` where
`mimetypes.guess_extension` returned nothing.

Integration: keep the corrected helper in the existing media sibling.
Use the shared MIME-extension resolver in the streamed cache path. The
original twenty-one filename cases pass against the real cache; the
four affected files pass 317 tests with zero retries.

(cherry picked from commit 69e68c3)
A queued message leaves its adapter slot before voice transcription and
recursive turn preparation finish. Sender deletion could therefore miss
that input and allow the deleted text or audio to start the next turn.
Overlapping busy handlers also replaced the lane's single reservation.

Keep each preparing input in the existing dispatch reservations until
steering, redirecting or turn dispatch claims it. Withdrawal rebuilds the
remaining contributions in place, including admission copies, so waiting
preparers cannot continue with a detached event. Revision checks repeat
transcription and inbound preparation after partial withdrawal and check
again after transcript echoes and processing hooks.

Cancellation restores unclaimed input in FIFO order and releases the
captured reservation. A previous task's completion cannot release a new
owner. Shutdown snapshots and capacity checks enumerate all accepted
reservations, while new intake preparations do not bypass queue limits.

The two lifecycle contracts reproduce deletion during STT, transcript
echoes, admission rewrites and recursive preparation. They also verify
partial voice retention, overlapping busy input and the irreversible
claim boundary under cancellation. Automatic durable restoration remains
a separate consumer of the pending snapshots.

The pending-input protocol resolves the session key before the pause
gate. The existing paused-turn test used a bare runner and a source
without thread identity, so it failed before checking the paused reply.
Construct a real GatewayRunner, MessageEvent and SessionSource while
preserving its three original paused-reply assertions.

This owner integrates only the pause-fixture hunk from original campaign
correction 61048ea. The native
locale-warming hunk has separate NousResearch#126281 and NousResearch#128952 union destinations.
This unit-fixture correction provides no new native validation.

Cancellation before transcript ownership keeps the provisional input for
shutdown recovery. Exact durable input ownership excludes active input
from pending capacity. Withdrawal closes at the original synchronous
dispatch boundary, separately from recovery ownership. Plugin consumption,
lease refusal and completed handlers still finish their dispatch claims.

Retain linked pending reservations until cancelled processing completes.
The completion callback and waiter share reconciliation for each original
input, so non-durable input returns to the queue once and durable input
is excluded. Preserve the existing synchronous withdrawal boundary and
current ownership fixtures when composing the updated prerequisite.

Close the withdrawal fixtures and sweep their isolated test-home registry after the original task cleanup. The lease fixture also replaces a constructor store, so closing its replacement alone leaves a registry reference. Preserve all original phase and partial-input assertions.
When the Matrix adapter could not download or cache an inbound
attachment for a reason other than its size, such as a network error,
an empty download or bytes that the image cache rejects, it gave the
agent the attachment's `/_matrix/client/v1/media/download/...` URL
instead of a cached file. That endpoint requires the bot's access
token, so the agent's attempts to fetch the URL failed, and the model
could repeat the URL in its reply. Encrypted media had no URL to fall
back to: the event kept its media message type with no attachment, and
nothing told the agent that the user had sent one.

When the download or cache step produces no file, the adapter now
sends a text event with the user's caption followed by a marker such
as `[matrix image attachment could not be downloaded: photo.png]`. The
marker has the same shape as the `[matrix <kind> attachment too large:
<filename>]` marker proposed in NousResearch#125637, and `_inbound_media_marker`
builds it so that the oversized-media path can use the same helper.
The filename is the declared `filename`, or a body that contains only
the transport filename once any reply fallback is removed. The event
has no media URL or media type, so downstream tools do not treat the
attachment as downloaded.

`_mxc_to_http` has no other caller and is removed.

Integration: use the shared filename rule and preserve the canonical
sticker kind, retained reply context, mention claim and admission result.
Oversize failures still take precedence. The current SDK never returns
None, so the file failure fixture uses the subsequent source timeout
correction at the streamed download boundary. The affected gate passes
319 tests; four original failure cases were reproduced before the fix.

(cherry picked from commit cdc5ce6)
The previous commit said that an empty download gets the new marker.
That is true only for images. mautrix's `download_media` returns the
response body or raises; it never returns `None`. An empty body is
`b""`, which the image cache rejects because it has no image signature,
so an empty `m.image` gets the marker. The audio and document caches
write an empty body as an empty file, so an empty `m.file` or `m.audio`
still reaches the agent as an empty cached file with no marker. The
failures that do produce the marker are a download that raises, bytes
that the image cache rejects, and missing decryption metadata for
encrypted media.

The file case of the test mocked `download_media` to return `None`,
which only a mock does. It now raises `TimeoutError`, as a stalled
download does.

The test also covers `m.file` and `m.audio`, so it moves out of
`TestMatrixImageOnlyMediaNormalization` into a class of its own.

Integration: the preceding adapted streamed fixture already uses the
real TimeoutError case. Preserve this contribution as the separate test
class move. All three original cases pass in the new class, and the
previous four-file gate has 319 passing tests.

(cherry picked from commit cb2c0db)
A media download failure must remain visible to the model without an
HTTP download URL which requires the bot token. The filename and failure
repairs also need to preserve the adapter's retained reply context,
mention claim and consumed-event result when the input becomes text.

Exercise a real homeserver 404, image-cache rejection and encrypted
attachment without its required hash through the independent Matrix
client and gateway. Compare the complete user-message record before the
separate first-message onboarding note, and confirm that no attachment
was cached. Keep the existing caption and both oversize cases unchanged.

Add one parameterised adapter contract for the retained parent, both
admission outcomes, sticker classification and size-limit precedence.
The same contract checks that empty audio and documents remain cached,
and that missing-URL inputs keep their existing behaviour.

The unfixed parent fails the five changed admission cases while six
controls pass. The fixed adapter passes all eleven cases. The live
parent reproduces the missing failure markers before fixed-image
validation.
Reaction menus were deliberately separate from the hermes-matrix bundle.
Saving that bundle as the Matrix tool selection therefore did not enable
menus, even though an explicit Matrix selection opts in to its native
default-off tools.

Include present_menu in the Matrix bundle. An implicitly selected bundle
still excludes reaction_menu through the existing default-off filter,
and the platform restriction keeps the tool out of other sessions. Update
the tool references and Matrix guide to explain both ways to opt in.

Check the real platform resolver with an explicit bundle on Matrix, CLI,
Telegram and API-server, alongside the implicit default in each case.
The complete Matrix feature sources define polls and reaction menus, but
current platform selection expands the shared hermes-matrix bundle. The
older feature changes did not update that current bundle, so native poll
tools were absent and an explicitly enabled menu composite could not
resolve its tool.

Add the original feature tools to the existing Matrix bundle. The menu
remains off by default and platform restrictions still exclude both tool
families elsewhere.

Tool discovery also imported the Matrix adapter through the read tool's
discovery request type. Parse that request through a local import only
when the read tool runs. The original poll discovery and platform
selection contracts now pass on the complete feature composition.

Public adaptation: The poll bundle is already implemented in NousResearch#126366.
NousResearch#126273 repairs builtin discovery at its feature introducer. NousResearch#126349
extends the menu policy so an explicitly saved hermes-matrix selection
enables menus while the implicit default remains off. The original menu
feature documented separate opt-in; bundle inclusion is a policy
extension. This contribution records the complete attribution after the
three public source effects have been independently accepted.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 6, 2026
The complete Matrix feature sources define polls and reaction menus, but
current platform selection expands the shared hermes-matrix bundle. The
older feature changes did not update that current bundle, so native poll
tools were absent and an explicitly enabled menu composite could not
resolve its tool.

Add the original feature tools to the existing Matrix bundle. The menu
remains off by default and platform restrictions still exclude both tool
families elsewhere.

Tool discovery also imported the Matrix adapter through the read tool's
discovery request type. Parse that request through a local import only
when the read tool runs. The original poll discovery and platform
selection contracts now pass on the complete feature composition.

Public adaptation: The poll bundle is already implemented in NousResearch#126366.
NousResearch#126273 repairs builtin discovery at its feature introducer. NousResearch#126349
extends the menu policy so an explicitly saved hermes-matrix selection
enables menus while the implicit default remains off. The original menu
feature documented separate opt-in; bundle inclusion is a policy
extension. This contribution records the complete attribution after the
three public source effects have been independently accepted.
@iainlane
iainlane force-pushed the feat/matrix-reaction-menus branch from 332afec to 8ee2b8f Compare October 6, 2026 05:30
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 6, 2026
The complete Matrix feature sources define polls and reaction menus, but
current platform selection expands the shared hermes-matrix bundle. The
older feature changes did not update that current bundle, so native poll
tools were absent and an explicitly enabled menu composite could not
resolve its tool.

Add the original feature tools to the existing Matrix bundle. The menu
remains off by default and platform restrictions still exclude both tool
families elsewhere.

Tool discovery also imported the Matrix adapter through the read tool's
discovery request type. Parse that request through a local import only
when the read tool runs. The original poll discovery and platform
selection contracts now pass on the complete feature composition.

Public adaptation: The poll bundle is already implemented in NousResearch#126366.
NousResearch#126273 repairs builtin discovery at its feature introducer. NousResearch#126349
extends the menu policy so an explicitly saved hermes-matrix selection
enables menus while the implicit default remains off. The original menu
feature documented separate opt-in; bundle inclusion is a policy
extension. This contribution records the complete attribution after the
three public source effects have been independently accepted.
The notification contracts can use the installed discord.py module when
it is already loaded. Their thread helpers still called Thread without
arguments, which only worked with the fallback shim. Five existing
thread notification cases failed before reaching the normaliser.

Allocate the fixture instance without calling its SDK constructor, then
populate the fields which the notification normaliser reads. The real
Thread class still supplies the adapter's isinstance contract. Keep the
existing test bodies and assertions unchanged.

All 17 notification cases pass with the installed SDK. The separate raw
deletion contracts construct real SDK guild and thread payloads.
Busy interrupts and post-turn drains share the media placeholder builder.
The builder is in the oversized runner facade, while attachment transfer
already belongs to the inbound media sibling.

Move the builder without changing its behaviour and update internal
consumers to import its defining module. Classification still uses the
existing facade definitions. This isolates the renderer before changing
its profile handling.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 7, 2026
The complete Matrix feature sources define polls and reaction menus, but
current platform selection expands the shared hermes-matrix bundle. The
older feature changes did not update that current bundle, so native poll
tools were absent and an explicitly enabled menu composite could not
resolve its tool.

Add the original feature tools to the existing Matrix bundle. The menu
remains off by default and platform restrictions still exclude both tool
families elsewhere.

Tool discovery also imported the Matrix adapter through the read tool's
discovery request type. Parse that request through a local import only
when the read tool runs. The original poll discovery and platform
selection contracts now pass on the complete feature composition.

Public adaptation: The poll bundle is already implemented in NousResearch#126366.
NousResearch#126273 repairs builtin discovery at its feature introducer. NousResearch#126349
extends the menu policy so an explicitly saved hermes-matrix selection
enables menus while the implicit default remains off. The original menu
feature documented separate opt-in; bundle inclusion is a policy
extension. This contribution records the complete attribution after the
three public source effects have been independently accepted.
@iainlane
iainlane force-pushed the feat/matrix-reaction-menus branch 2 times, most recently from 51dbe39 to 64df3ae Compare October 8, 2026 22:12
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 8, 2026
The complete Matrix feature sources define polls and reaction menus, but
current platform selection expands the shared hermes-matrix bundle. The
older feature changes did not update that current bundle, so native poll
tools were absent and an explicitly enabled menu composite could not
resolve its tool.

Add the original feature tools to the existing Matrix bundle. The menu
remains off by default and platform restrictions still exclude both tool
families elsewhere.

Tool discovery also imported the Matrix adapter through the read tool's
discovery request type. Parse that request through a local import only
when the read tool runs. The original poll discovery and platform
selection contracts now pass on the complete feature composition.

Public adaptation: The poll bundle is already implemented in NousResearch#126366.
NousResearch#126273 repairs builtin discovery at its feature introducer. NousResearch#126349
extends the menu policy so an explicitly saved hermes-matrix selection
enables menus while the implicit default remains off. The original menu
feature documented separate opt-in; bundle inclusion is a policy
extension. This contribution records the complete attribution after the
three public source effects have been independently accepted.
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.

Apply these complete published fake-API and reconnect fixture scopes to the preserved supplier source. The original fixture definitions predate the campaign, and all original assertions remain unchanged.
Discord event normalization and Slack's inbound guard still occupy their
large adapter facades. Move those methods into their platform siblings,
with deferred facade bindings for runtime SDK and helper resolution.

Extract the pending withdrawal traversal behind its existing sender
predicate so native deletion notifications can reuse every pending store.
The traversal and existing deletion policy remain unchanged.

Compose the platform-event and inbound-context extraction with the current prerequisite. Remove the extracted facade implementations and retain their complete runtime contracts in the defining mixins, including current builtin container annotations, explicit receiver types and the Slack facade helper binding.
@iainlane
iainlane force-pushed the feat/matrix-reaction-menus branch from 64df3ae to 6bcc189 Compare October 10, 2026 02:11
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 10, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
An expiry notice for a reaction menu appeared in the room timeline even
when the menu belonged to a thread. The pending picker kept the request
and expiry time but discarded its send metadata, so the feedback sender
received no thread root or card fallback target.

Keep a copy of the picker metadata and pass the card's notice metadata
through the expiry path. The metadata projection uses the existing
threaded-control schema from NousResearch#128710. The sender already distinguishes
notices from continuation output, so the notice replies to the expired
card even after another message appears in the thread. With rich replies
disabled, the thread fallback still targets that card.

The real adapter regression covers threaded and timeline menus with
rich replies enabled and disabled. Both threaded cases fail before the
fix; the complete menu test file passes after it. Expiry still removes
the control and never starts a selected turn.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 11, 2026
A menu pins the session ID from the start of the turn that presented
it, and the runner admits the choice only while that ID is still the
route's current session. When the conversation is compressed during
that turn or within the five-minute window, the route moves to the
compression child. The requester's reaction then consumed the menu,
but the choice was dropped without a turn or a reply. The same strict
check applies to plugin message injection, so a plugin event queued
behind a compressing turn was lost in the same way.

Compression continues a conversation; only /new and /reset end it. The
heartbeat admission path already accepted an event whose pinned
session has the current session as its compression tip. Move that
lookup into one helper and use it for strict internal events too. A
choice from a menu whose conversation has been reset is still refused,
but the event now carries a notice that the runner sends when it drops
the event, so the requester learns why nothing happened. A requester
whose authorisation was revoked also receives a reply instead of
silence.

The menu callback no longer looks the session up itself. That lookup
took the session store's lock on the gateway loop, and the runner
repeats the check when it dequeues the event, which also covers a
reset while the choice waits behind a running turn.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 11, 2026
The plugin guide said that Hermes drops a gateway injection if "the
session rotates before handling starts". Earlier in this branch, strict
internal events began to follow the compression chain of their pinned
session, and plugin injections are strict events. An injection queued
behind a turn that compresses the conversation now runs on the
compression child, where it used to be dropped. The guide therefore
described behaviour that no longer exists.

State the current contract: Hermes drops the request when topic
recovery changes the route or the conversation is reset, for example
with /new, and keeps it through context compression. A parameterised
invariant dispatches a real injection through a SessionStore and
SessionDB, then resolves it after a compression split and after a
reset. The compression case fails on the parent branch, where the event
was dropped, and passes here; the reset case drops the event silently
on both.

The compression and reset cases create a real SessionStore, but the test
does not close its database handles after either route. Register the
store finalizer through the request fixture so both parametrised cases
release their handles during teardown. Preserve all current source
guards and behavioural assertions.

Apply the independently published menu fixture correction from PR
NousResearch#126349 at 6bcc189.

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/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.