Repository navigation
Conversation
iainlane
force-pushed
the
feat/matrix-reaction-menus
branch
from
October 1, 2026 09:36
dda37f2 to
bd75ec3
Compare
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
force-pushed
the
feat/matrix-reaction-menus
branch
from
October 6, 2026 05:30
332afec to
8ee2b8f
Compare
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.
This was referenced Oct 6, 2026
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
force-pushed
the
feat/matrix-reaction-menus
branch
2 times, most recently
from
October 8, 2026 22:12
51dbe39 to
64df3ae
Compare
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
force-pushed
the
feat/matrix-reaction-menus
branch
from
October 10, 2026 02:11
64df3ae to
6bcc189
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
All PRs shown target
main. Nesting records merge order: each parent must merge before its child. 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_menuwith 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
/newis 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
/newor/reset. The plugin guide says so.Other platforms
reaction_menuis a new toolset containingpresent_menu. It is off by default and restricted to Matrix sessions, so other platforms never see the tool.hermes toolslists it as "Reaction Menus".TurnRunnersets apresent_menucallback on every agent that it runs, and the inline tool executor table gainspresent_menu. The tool reaches the model only when the toolset is enabled for a Matrix session.Configuration and documentation
hermes tools, or explicitly selectreaction_menuorhermes-matrixinplatform_toolsets.matrixinconfig.yaml. An implicit default selection keeps reaction menus off. Other platforms never receivepresent_menu. Restart the gateway or start a new session after changing toolsets.reaction_menuandpresent_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_menuto 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
/newclears the queue while a choice waits behind the turn that presented the menu, the choice is discarded without a notice.