feat(mattermost): add ambient session ingestion mode - #26901
Conversation
… thread
Two bugs surfaced when MATTERMOST_REPLY_MODE=thread was enabled:
1. Replies inside an existing thread failed with HTTP 400 from
POST /api/v4/posts:
api.post.create_post.root_id.app_error
"Invalid RootId parameter."
The adapter was passing reply_to (= the user's message id) straight
through as root_id, but Mattermost requires root_id to reference the
thread *root* — a post that has no root_id of its own. When the user
replied inside an existing thread, their post id was a reply, so the
server rejected it. Reproduced with a direct API call: a root_id
pointing at a reply returns 400 with the same error code; pointing at
a top-level post succeeds.
Fix: add a small _resolve_thread_root helper that does one cached
GET /posts/{id} lookup and returns the post's root_id when present,
otherwise the id itself. The three send sites (text, single media,
multi-image) all route reply_to through the helper, so root_id always
points at a real thread root. Missing/deleted posts fall back to the
original id so callers see the same error they would have seen
pre-fix instead of a silent rewrite.
2. The typing indicator showed up in the main channel even when the
user was conversing inside a thread. The dispatcher already passes
metadata={"thread_id": event.source.thread_id} into send_typing, but
the adapter ignored it and only sent {"channel_id": chat_id}.
Fix: when metadata carries a thread_id, forward it as Mattermost's
parent_id field so "hermes is typing…" appears inside the thread
the user just messaged from.
Tests: existing test_send_with_thread_reply updated to mock the new
_api_get lookup (still asserts that a root post is used unchanged), plus
new coverage for resolution-from-reply, lookup caching, missing-post
fallback, and parent_id behaviour in send_typing.
…hread
Even after threading was wired up for the main reply, tool-call previews,
reasoning chunks, background-task notifications, and other intermediate
sends still landed in the main channel.
Cause: the dispatcher fires those sends as
adapter.send(chat_id, content, metadata={"thread_id": ...})
— with no `reply_to` — but the Mattermost adapter was only computing
`root_id` from `reply_to`. With no `reply_to`, no `root_id` got attached,
so the post defaulted to the main channel. Media helpers had a related
bug: `send_image` / `send_image_file` / `send_document` / `send_voice`
/ `send_video` accept `metadata` but were dropping it on the way to the
internal `_send_url_as_file` / `_send_local_file` helpers, and
`send_multiple_images` never set `root_id` at all.
Fix: factor a `_root_id_for_payload(reply_to, metadata)` helper that
prefers an explicit `reply_to` (resolved to the thread root) and falls
back to `metadata['thread_id']` (already a root post id from
`event.source.thread_id`). Use it at every outbound-post site:
- `send` (text)
- `_send_url_as_file` (image URLs)
- `_send_local_file` (local image / file / audio / video)
- `send_multiple_images` (Mattermost-native multi-image posts)
Plumb `metadata` through the public media methods to the internal helpers.
Reply mode "off" continues to suppress threading entirely; the helper
returns `None` in that case.
Tests: covers the metadata-only fallback (root_id from thread_id),
explicit `reply_to` winning over metadata, reply_mode "off" suppressing
both, and `metadata` being forwarded through every public media method to
its internal helper.
Thread mode now treats handled top-level channel posts as the root of the bot's reply thread, so progress messages, streaming chunks, media sends, and follow-up replies share the same thread metadata and Hermes session key. DMs stay unthreaded unless Mattermost provides a real root_id so their stable DM session behavior is preserved. Also avoid caching failed post lookups in _resolve_thread_root, since _api_get returns an empty dict for transient API/network failures as well as missing posts. When reply_to resolution fails, prefer trusted dispatcher thread metadata before falling back to the original reply id. Typing indicators now honor reply_mode too: parent_id is only sent when thread mode is enabled, matching the actual response routing. Tests cover top-level channel thread metadata, DM preservation, lookup failure caching, metadata fallback, and reply_mode-gated typing.
Introduce MATTERMOST_AMBIENT_CHANNELS (alias: MATTERMOST_SILENT_SESSION_CHANNELS) env vars to support passive context accumulation in Mattermost channels without triggering an LLM response on every message. Behavior: - Normal channel: message ignored unless @mentioned - Free-response channel (MATTERMOST_FREE_RESPONSE_CHANNELS): immediate LLM run - Ambient channel: message stored silently in session history; LLM only runs on explicit @mention, slash command, webhook, or other external trigger Implementation: - Add `trigger_llm: bool = True` field to `MessageEvent` in base.py - `BasePlatformAdapter.handle_message()` short-circuits when trigger_llm=False: calls session_store.get_or_create_session() + append_to_transcript() then returns without invoking _message_handler or spawning background tasks - `MattermostAdapter._handle_ws_event()` detects ambient channels, sets trigger_llm=False when no @mention is present, trigger_llm=True on @mention - Config also reads `ambient_channels` key from config.yaml extra block Env vars: MATTERMOST_AMBIENT_CHANNELS comma-separated channel IDs (primary) MATTERMOST_SILENT_SESSION_CHANNELS alias for the above
Ambient messages now stored as '[username]: text' so the agent has full sender context when it is eventually triggered via @mention. sender_id and sender_name are also stored as transcript metadata.
…ity/hermes-agent Triggers on push to feature/mattermost-ambient-session-ingestion. Pushes :latest and :<sha> tags to ghcr.io/coding-reality/hermes-agent. Requires CR_PAT secret on the fork with write:packages access.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the ambient-ingestion proposal. The use case is valid, but the current implementation has blocking correctness and boundary issues.
Problems
gateway/platforms/base.py:2834writes and returns before_message_handler. On current main that handler isGatewayRunner._handle_message(gateway/run.py:7063), where authorization occurs atgateway/run.py:8958-8998; unapproved senders can therefore inject transcript content.- The proposed write uses
event.source(gateway/platforms/base.py:2837), while Mattermost sources retain each sender ID (gateway/platforms/mattermost.py:932-946). Current session keys isolate normal group/channel sessions per user by default (gateway/session.py:892-958,gateway/config.py:700), so it does not produce the claimed shared accumulated channel context. .github/workflows/build-cr-image.yml:6-55is unrelated branch-specific publishing automation and must not be part of this feature.
Suggested changes
- Rework against the current plugin adapter at
plugins/platforms/mattermost/adapter.py(migrated inaf973e407). - Preserve the gateway authorization boundary and define shared-context semantics explicitly. The documented
pre_gateway_dispatchhook already supports silent ingestion/buffering (website/docs/user-guide/features/hooks.md:999-1057). - Keep behavior configuration in
config.yamland remove the unrelated workflow.
Automated hermes-sweeper review.
| return | ||
|
|
||
| # Ambient ingestion: store to session history without invoking the LLM. | ||
| if not event.trigger_llm: |
There was a problem hiding this comment.
This returns before the GatewayRunner handler, which performs the normal authorization/pairing gate. An unapproved participant can therefore write [sender]: text into a session that an authorized user later loads. Preserve authorization before any ambient transcript write.
| if not event.trigger_llm: | ||
| if self._session_store is not None: | ||
| try: | ||
| session_entry = self._session_store.get_or_create_session(event.source) |
There was a problem hiding this comment.
This derives the transcript from the individual sender's event.source. With the default per-user group-session isolation, ambient posts from different channel members land in different sessions, so the later mention cannot receive the claimed channel-wide accumulated context.
| on: | ||
| push: | ||
| branches: | ||
| - feature/mattermost-ambient-session-ingestion |
There was a problem hiding this comment.
Remove this branch-specific publishing workflow from the feature PR. It is unrelated to Mattermost ingestion and grants package-publishing capability for an external Coding-Reality image.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two PRs address the same Mattermost ambient-ingestion use case: both add a trigger_llm path that writes unmentioned channel messages directly to session transcripts, while #26901 resubmits #26663 and adds unrelated thread-mode and Codex-stream changes. The shared implementation does not yet fix the reported cause safely because it bypasses authorization and, under default per-user session isolation, does not create the claimed shared channel context.
Related pull requests
- #26663 [closed]
duplicate— (+771/-45) — superseded: Closed #26663 remains relevant as the original implementation, but its ambient-ingestion diff is carried forward substantially unchanged in #26901; it also mixes in Mattermost thread fixes and branch-specific image-publishing automation. - #26901
related— (+954/-60) — keep open for substantial rework, not merge-ready: The contributor keep_open review on #26901 is supported by the diff: silent writes return before the gateway authorization handler, use sender-specific sources that preserve default per-user session isolation instead of shared channel context, target the pre-migration Mattermost adapter, and include unrelated publishing, thread-mode, and Codex-stream changes.
Duplicates
#26663 and #26901 implement substantially the same ambient-ingestion change; #26901 is the superseding resubmission and contains additional unrelated changes.
Suggested consolidation
Do not merge #26901 yet; retain it as the single open consolidation point only if the author rebases the feature onto plugins/platforms/mattermost/adapter.py, moves ingestion behind authorization, defines session keying that actually provides the intended accumulated channel context, and removes the unrelated workflow, thread-mode, and Codex-stream changes. This follows the contributor keep_open review on #26901 while treating its listed issues as blocking; #26663 is already closed and can remain closed as superseded by #26901.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup26663 ["PRs duplicating each other"]
P26663["PR #26663 (closed)"]
P26901["PR #26901 (open)"]
end
class P26663 closed
class P26901 open
class P26901 target
click P26663 "https://github.com/NousResearch/hermes-agent/pull/26663"
click P26901 "https://github.com/NousResearch/hermes-agent/pull/26901"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 104 kB of PR diffs, 4 kB of issue/PR text, 2 kB of discussion (3 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
Summary
Add support for ambient session ingestion in the Mattermost connector — a new channel mode where messages are silently stored into Hermes session history without triggering an LLM response.
Also includes the changes from #20874 (Mattermost thread-mode fixes), merged in.
Problem
The existing
MATTERMOST_FREE_RESPONSE_CHANNELSbypasses mention gating and immediately invokeshandle_message()for every message. In multi-agent and large-team environments this causes:Solution
Introduce a new env var:
Behavior matrix:
Ambient messages are stored with sender attribution (
[username]: text) so the agent has full context on who said what when eventually triggered.Implementation
gateway/platforms/base.pytrigger_llm: bool = Truefield toMessageEventBasePlatformAdapter.handle_message()short-circuits whentrigger_llm=False:session_store.get_or_create_session()+append_to_transcript()with sender attribution_message_handleror spawning background tasksgateway/platforms/mattermost.pyMATTERMOST_AMBIENT_CHANNELS(also readable fromconfig.yamlviaambient_channelskey)_handle_ws_event(): detect ambient channels, settrigger_llm=Falsewhen no @mention present,trigger_llm=Trueon @mentiontrigger_llmtoMessageEventconstructorUse cases