Skip to content

feat(mattermost): add ambient session ingestion mode - #26901

Open
And1rew132 wants to merge 12 commits into
NousResearch:mainfrom
Coding-Reality:feature/mattermost-ambient-session-ingestion
Open

feat(mattermost): add ambient session ingestion mode#26901
And1rew132 wants to merge 12 commits into
NousResearch:mainfrom
Coding-Reality:feature/mattermost-ambient-session-ingestion

Conversation

@And1rew132

Copy link
Copy Markdown

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_CHANNELS bypasses mention gating and immediately invokes handle_message() for every message. In multi-agent and large-team environments this causes:

  • Excessive token usage
  • Response loops between agents
  • Noisy behavior in busy channels
  • No way to build passive/ambient memory

Solution

Introduce a new env var:

MATTERMOST_AMBIENT_CHANNELS=channel_id_1,channel_id_2

Behavior matrix:

Channel type Message Result
Normal no mention ignored
Normal @mention LLM runs
Free-response any LLM runs immediately
Ambient no mention stored silently, no LLM
Ambient @mention LLM runs with accumulated context

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.py

  • Add trigger_llm: bool = True field to MessageEvent
  • BasePlatformAdapter.handle_message() short-circuits when trigger_llm=False:
    • Calls session_store.get_or_create_session() + append_to_transcript() with sender attribution
    • Returns without invoking _message_handler or spawning background tasks

gateway/platforms/mattermost.py

  • Parse MATTERMOST_AMBIENT_CHANNELS (also readable from config.yaml via ambient_channels key)
  • In _handle_ws_event(): detect ambient channels, set trigger_llm=False when no @mention present, trigger_llm=True on @mention
  • Pass trigger_llm to MessageEvent constructor

Use cases

  • Multi-agent orchestration (prevent response loops)
  • Passive memory accumulation
  • Background context awareness
  • Enterprise chat environments
  • Mattermost-based operational workflows

potatosalad and others added 9 commits May 6, 2026 09:17
… 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.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery labels May 16, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This appears to be a resubmission of closed #26663 (same author, same title). Also includes changes from #20874. Reviewers: please check what changed vs the previous submission.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:2834 writes and returns before _message_handler. On current main that handler is GatewayRunner._handle_message (gateway/run.py:7063), where authorization occurs at gateway/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-55 is 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 in af973e407).
  • Preserve the gateway authorization boundary and define shared-context semantics explicitly. The documented pre_gateway_dispatch hook already supports silent ingestion/buffering (website/docs/user-guide/features/hooks.md:999-1057).
  • Keep behavior configuration in config.yaml and remove the unrelated workflow.

Automated hermes-sweeper review.

Comment thread gateway/platforms/base.py
return

# Ambient ingestion: store to session history without invoking the LLM.
if not event.trigger_llm:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gateway/platforms/base.py
if not event.trigger_llm:
if self._session_store is not None:
try:
session_entry = self._session_store.get_or_create_session(event.source)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added 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-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 13, 2026

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Loading

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.

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

Labels

area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data 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.

5 participants