From 3e8d6bc87e004509b2d84dd3b71d140c6fd205c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 12:45:01 +0000 Subject: [PATCH 01/13] docs: add ADR for Ambient Mode --- docs/steering/adr-ambient-mode.md | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/steering/adr-ambient-mode.md diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md new file mode 100644 index 000000000..59a9b9b32 --- /dev/null +++ b/docs/steering/adr-ambient-mode.md @@ -0,0 +1,138 @@ +# ADR: Ambient Mode + +- **Status:** Proposed +- **Date:** 2026-06-26 +- **Author:** Pahud Hsieh + +## Context + +Today, OpenAB only dispatches messages to an agent when the agent is explicitly +mentioned (or the message is in a thread the agent participates in). This means +agents are deaf to surrounding conversation unless invoked — they cannot +proactively contribute context, answer questions addressed to no one in +particular, or notice when a discussion touches their area of expertise. + +We want agents to behave more like attentive team members who listen to the room +and speak up when they have something valuable to add, without requiring an +explicit `@mention` every time. + +## Decision + +Introduce an **Ambient Mode** that can be enabled per-agent per-channel. + +### Mechanism + +``` +┌────────────────────────────────────────────────────────┐ +│ Discord Channel │ +│ │ +│ User A: "Anyone know how to fix the helm release?" │ +│ │ +└──────────────────────────┬─────────────────────────────┘ + │ (all messages dispatched) + ▼ +┌──────────────────────────────────────────────────────────┐ +│ OpenAB Gateway │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Ambient Dispatch │ │ +│ │ │ │ +│ │ • Forward message to agent │ │ +│ │ • Prepend system instruction: │ │ +│ │ "You are in ambient mode. If you have nothing │ │ +│ │ valuable to add, reply exactly: [NO_REPLY]" │ │ +│ └──────────────────────────────┬──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐│ +│ │ Response Router ││ +│ │ ││ +│ │ • Agent replies "[NO_REPLY]" → discard silently ││ +│ │ • Agent replies with content → post to Discord ││ +│ └──────────────────────────────────────────────────────┘│ +└──────────────────────────────────────────────────────────┘ +``` + +1. When ambient mode is enabled for a channel, **every message** in that channel + is dispatched to the agent — regardless of mentions. +2. The dispatch payload includes a system-level instruction telling the agent: + *"You are in ambient listening mode. Only reply if you have something + genuinely useful to add. Otherwise respond exactly with `[NO_REPLY]`."* +3. If the agent's response is the literal sentinel `[NO_REPLY]`, OpenAB + discards it and sends nothing back to Discord. +4. Otherwise the response is posted normally. + +### Configuration + +```toml +[discord] +bot_token = "${DISCORD_BOT_TOKEN}" + +[discord.ambient] +enabled = true +channels = ["1490282656913559673"] # channels where ambient mode is active +cooldown_seconds = 300 # min gap between unsolicited replies +context_window = 20 # recent messages to include as context +``` + +- `enabled` — master switch (default: `false`). +- `channels` — allowlist of channel IDs. Empty = all allowed channels. +- `cooldown_seconds` — after the agent posts an ambient reply, suppress further + ambient replies for this many seconds (prevents chattiness). Explicitly + mentioned messages bypass cooldown. +- `context_window` — number of recent messages to include for context when + dispatching. + +### Sentinel Value + +The sentinel is `[NO_REPLY]` (case-insensitive, trimmed). Chosen because: +- Unlikely to appear in natural agent output. +- Simple to detect with a string match (no regex needed). +- Easy for any LLM to produce reliably. + +## Consequences + +### Benefits + +- Agents behave like real team members — aware of context, able to contribute + organically. +- Zero additional infrastructure; the mechanism reuses existing dispatch and + response paths. +- User-configurable: operators decide the cost/intelligence trade-off themselves. + +### Trade-offs + +- **Token cost increases** — every channel message triggers an LLM invocation + (even if the result is `[NO_REPLY]`). Mitigations: + - Per-channel opt-in keeps blast radius small. + - Cooldown prevents runaway cost from high-traffic channels. + - Future: add a lightweight keyword/semantic pre-filter before dispatching. +- **Potential for noise** — a poorly-tuned prompt or model may reply too eagerly. + Cooldown + explicit prompt instructions + the ability to disable per-channel + mitigate this. +- **Session management** — ambient messages should not create long-lived + sessions. They should use a short-lived or stateless invocation so the session + pool isn't exhausted. + +## Alternatives Considered + +1. **Keyword pre-filter** — only dispatch if the message matches certain + keywords. Rejected as primary mechanism because it defeats the purpose of + intelligent, context-aware participation. May be added later as a cost + optimization layer. +2. **Periodic summary mode** — batch N messages and summarize them to the agent + periodically. Rejected because it loses real-time interactivity. +3. **Separate lightweight classifier** — use a small/cheap model to decide + whether to invoke the main agent. Viable as a future enhancement but adds + complexity for v1. + +## Implementation Notes + +- The `allow_bot_messages` and `allow_user_messages` config options already + provide some dispatch filtering logic in `src/discord.rs`. Ambient mode can + be implemented as a new dispatch path that coexists with the existing + mention-based path. +- The `[NO_REPLY]` check should be applied in the response router + (`src/adapter.rs`) before calling `send_message`. +- Reactions (👀, 🤔, etc.) should be suppressed for ambient dispatches to avoid + spamming the channel with status indicators on every single message. From d75cef3ba848aacbd6a487ad45dc60b5d14a961f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 12:52:45 +0000 Subject: [PATCH 02/13] docs: update ADR with batch flush strategy and prior art --- docs/steering/adr-ambient-mode.md | 223 ++++++++++++++++++++---------- 1 file changed, 151 insertions(+), 72 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index 59a9b9b32..764e7a15d 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -16,72 +16,137 @@ We want agents to behave more like attentive team members who listen to the room and speak up when they have something valuable to add, without requiring an explicit `@mention` every time. +## Prior Art + +### OpenClaw — `/activation always` + +OpenClaw supports an `always` activation mode for group chats: +- Every message is dispatched to the agent (no mention required). +- Agent returns the sentinel token `NO_REPLY` when it has nothing to add; + the gateway discards silently. +- Pending messages (up to 50) are accumulated as context and injected as + `[Chat messages since your last reply - for context]`. +- Per-group toggle via `/activation always` or `/activation mention`. +- **Limitation:** messages are dispatched one-by-one — each message triggers a + separate LLM invocation, even if it results in `NO_REPLY`. No batching. + +### Hermes Agent — `free_response_channels` + +Hermes provides `DISCORD_FREE_RESPONSE_CHANNELS` and +`DISCORD_REQUIRE_MENTION=false`: +- The bot responds to **every** message in designated channels without mention. +- History backfill (`DISCORD_HISTORY_BACKFILL`) recovers missed context when + the bot is later @mentioned. +- **Limitation:** no autonomous decision-making — the bot always replies. There + is no `NO_REPLY` equivalent; it's either "respond to everything" or + "respond only on mention." + +### Research — "Controlling AI Agent Participation in Group Conversations" + +(arXiv 2501.17258) — studies user preferences for AI agent behavior in group +settings. Key finding: users disliked agents that dominated the conversation +and preferred controls over when/how the agent participates. + +### Gap Our Design Fills + +Neither OpenClaw nor Hermes implements **batch flush** — they dispatch per +message. Our design accumulates messages and flushes them as a batch, which: +1. Reduces LLM invocations (one call per batch instead of N). +2. Gives the agent fuller conversational context for better judgment. +3. Provides natural rate-limiting without additional cooldown mechanisms. + ## Decision -Introduce an **Ambient Mode** that can be enabled per-agent per-channel. +Introduce an **Ambient Mode** using a **batch flush** strategy. ### Mechanism ``` -┌────────────────────────────────────────────────────────┐ -│ Discord Channel │ -│ │ -│ User A: "Anyone know how to fix the helm release?" │ -│ │ -└──────────────────────────┬─────────────────────────────┘ - │ (all messages dispatched) - ▼ -┌──────────────────────────────────────────────────────────┐ -│ OpenAB Gateway │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Ambient Dispatch │ │ -│ │ │ │ -│ │ • Forward message to agent │ │ -│ │ • Prepend system instruction: │ │ -│ │ "You are in ambient mode. If you have nothing │ │ -│ │ valuable to add, reply exactly: [NO_REPLY]" │ │ -│ └──────────────────────────────┬──────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────┐│ -│ │ Response Router ││ -│ │ ││ -│ │ • Agent replies "[NO_REPLY]" → discard silently ││ -│ │ • Agent replies with content → post to Discord ││ -│ └──────────────────────────────────────────────────────┘│ -└──────────────────────────────────────────────────────────┘ +Discord Channel +──────────────────────────────────────────────────────────────────── + msg1 (t=0s) │ + msg2 (t=3s) │ accumulate in buffer + msg3 (t=8s) │ + msg4 (t=12s) │ + ▼ + ┌─────────────────────────────┐ + │ Flush trigger fired │ + │ (60s elapsed OR 10 msgs) │ + └─────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ OpenAB Gateway │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Ambient Dispatch (batch) │ │ +│ │ │ │ +│ │ • Collect buffered messages as conversation context │ │ +│ │ • Prepend system instruction: │ │ +│ │ "You are in ambient mode. Below is a batch of recent │ │ +│ │ messages. If you have nothing valuable to add, reply │ │ +│ │ exactly: [NO_REPLY]" │ │ +│ │ • Send batch to agent │ │ +│ └────────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Response Router │ │ +│ │ │ │ +│ │ • Agent replies "[NO_REPLY]" → discard silently │ │ +│ │ • Agent replies with content → post to Discord │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Flush Triggers + +Messages are accumulated in a per-channel buffer and flushed when **either** +condition is met (whichever comes first): + +| Trigger | Default | Description | +|---------|---------|-------------| +| Time | `flush_interval_seconds = 60` | Seconds since first buffered message | +| Count | `flush_max_messages = 10` | Max messages to accumulate before flush | + +**Immediate flush override:** if any message in the buffer explicitly +`@mentions` the bot, the buffer is flushed immediately (no waiting) — users +should not have to wait 60 seconds when they directly address the agent. + +### Batch Payload + +The flushed batch is formatted as a conversation block: + ``` +[Ambient batch — 4 messages, channel: #general] -1. When ambient mode is enabled for a channel, **every message** in that channel - is dispatched to the agent — regardless of mentions. -2. The dispatch payload includes a system-level instruction telling the agent: - *"You are in ambient listening mode. Only reply if you have something - genuinely useful to add. Otherwise respond exactly with `[NO_REPLY]`."* -3. If the agent's response is the literal sentinel `[NO_REPLY]`, OpenAB - discards it and sends nothing back to Discord. -4. Otherwise the response is posted normally. +[12:00:01] UserA: Anyone know how to fix the helm release? +[12:00:04] UserB: Which chart version? +[12:00:11] UserA: 0.8.5 +[12:00:15] UserC: Try rolling back first + +[End of batch — reply only if you can add value. Otherwise reply exactly: [NO_REPLY]] +``` ### Configuration ```toml -[discord] -bot_token = "${DISCORD_BOT_TOKEN}" - [discord.ambient] enabled = true channels = ["1490282656913559673"] # channels where ambient mode is active -cooldown_seconds = 300 # min gap between unsolicited replies -context_window = 20 # recent messages to include as context +flush_interval_seconds = 60 # time-based flush trigger +flush_max_messages = 10 # count-based flush trigger +context_window = 20 # additional history before the batch ``` - `enabled` — master switch (default: `false`). - `channels` — allowlist of channel IDs. Empty = all allowed channels. -- `cooldown_seconds` — after the agent posts an ambient reply, suppress further - ambient replies for this many seconds (prevents chattiness). Explicitly - mentioned messages bypass cooldown. -- `context_window` — number of recent messages to include for context when - dispatching. +- `flush_interval_seconds` — max time to hold messages before flushing + (timer starts when the first message enters an empty buffer). +- `flush_max_messages` — max messages to buffer before flushing regardless + of time elapsed. +- `context_window` — number of historical messages (before the batch) to + include for additional context. ### Sentinel Value @@ -89,50 +154,64 @@ The sentinel is `[NO_REPLY]` (case-insensitive, trimmed). Chosen because: - Unlikely to appear in natural agent output. - Simple to detect with a string match (no regex needed). - Easy for any LLM to produce reliably. +- Consistent with OpenClaw's established `NO_REPLY` convention. ## Consequences ### Benefits -- Agents behave like real team members — aware of context, able to contribute - organically. -- Zero additional infrastructure; the mechanism reuses existing dispatch and - response paths. -- User-configurable: operators decide the cost/intelligence trade-off themselves. +- **Token efficient** — one LLM call per batch instead of per message. A + channel with 10 messages in 60 seconds costs 1 invocation, not 10. +- **Better judgment** — agent sees a complete conversational thread, making + it far more likely to know when a question was already answered (→ NO_REPLY) + vs. when it should contribute. +- **Natural rate limiting** — the flush interval acts as an inherent cooldown. + No separate cooldown mechanism needed. +- **Agents behave like real team members** — aware of context, able to + contribute organically. +- **User-configurable** — operators decide the cost/intelligence trade-off. ### Trade-offs -- **Token cost increases** — every channel message triggers an LLM invocation - (even if the result is `[NO_REPLY]`). Mitigations: - - Per-channel opt-in keeps blast radius small. - - Cooldown prevents runaway cost from high-traffic channels. - - Future: add a lightweight keyword/semantic pre-filter before dispatching. -- **Potential for noise** — a poorly-tuned prompt or model may reply too eagerly. - Cooldown + explicit prompt instructions + the ability to disable per-channel - mitigate this. -- **Session management** — ambient messages should not create long-lived - sessions. They should use a short-lived or stateless invocation so the session - pool isn't exhausted. +- **Latency** — ambient replies are delayed by up to `flush_interval_seconds`. + Acceptable because ambient replies are unsolicited; explicitly mentioned + messages bypass the buffer and get immediate dispatch via the normal path. +- **Token cost still increases** — even with batching, each flush is an LLM + call. Mitigations: per-channel opt-in, tunable flush interval/count. +- **Potential for noise** — a poorly-tuned prompt or model may reply too + eagerly. The batch format and explicit instructions mitigate this. +- **Session management** — ambient dispatches should use short-lived or + stateless sessions so the pool isn't exhausted. ## Alternatives Considered -1. **Keyword pre-filter** — only dispatch if the message matches certain +1. **Per-message dispatch (OpenClaw-style)** — dispatch every message + individually. Rejected because it burns N invocations for N messages, + most of which return NO_REPLY. Batch flush achieves the same goal with + ~1/N the cost. +2. **Keyword pre-filter** — only dispatch if the message matches certain keywords. Rejected as primary mechanism because it defeats the purpose of intelligent, context-aware participation. May be added later as a cost optimization layer. -2. **Periodic summary mode** — batch N messages and summarize them to the agent - periodically. Rejected because it loses real-time interactivity. 3. **Separate lightweight classifier** — use a small/cheap model to decide whether to invoke the main agent. Viable as a future enhancement but adds complexity for v1. +4. **Periodic summary mode** — batch N messages and summarize them before + sending to agent. Rejected because the agent should see raw messages for + full context; summarization loses nuance. ## Implementation Notes -- The `allow_bot_messages` and `allow_user_messages` config options already - provide some dispatch filtering logic in `src/discord.rs`. Ambient mode can - be implemented as a new dispatch path that coexists with the existing - mention-based path. +- A new `AmbientBuffer` struct per channel holds pending messages and a + flush timer. On flush, it formats the batch and sends to the agent via + the existing ACP dispatch path. - The `[NO_REPLY]` check should be applied in the response router (`src/adapter.rs`) before calling `send_message`. -- Reactions (👀, 🤔, etc.) should be suppressed for ambient dispatches to avoid - spamming the channel with status indicators on every single message. +- Reactions (👀, 🤔, etc.) should be suppressed for ambient dispatches to + avoid spamming the channel with status indicators. +- When a message in the buffer contains an explicit `@mention` of the bot, + the buffer should flush immediately and the dispatch should be marked as + "mention-triggered" (not ambient) so normal reply behavior applies. +- The `allow_bot_messages` and `allow_user_messages` config options already + provide dispatch filtering logic in `src/discord.rs`. Ambient mode adds a + new dispatch path that coexists with the existing mention-based path. From 7d7c707a87c80b2f5109185b245d88529b184736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 13:25:31 +0000 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20address=20all=20review=20findings?= =?UTF-8?q?=20(=E5=8F=A3=E6=B8=A1,=20=E6=A0=B8=E6=B8=A1,=20=E8=A6=BA?= =?UTF-8?q?=E6=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved: - [口渡 #1] channels empty = disabled (fail-safe), not all channels - [口渡 #2] Buffer lifecycle: swap-and-drain model, flush clears buffer - [口渡 #3] @mention triggers immediate flush + separate normal dispatch - [口渡 #4] Dedicated ambient session pool, isolated from main pool - [口渡 #5] Bot echo prevention: own messages never enter buffer - [口渡 #6] max_concurrent_flushes cap for global rate limiting - [口渡 #9] context_window = Discord API fetch, clarified semantics - [口渡 #10] Error handling table added (timeout, tool calls, etc.) - [核渡 #1] OpenClaw described as cross-platform group chat feature - [核渡 #2] Hermes backfill scope clarified (mention-only, skips free-response) - [核渡 #3] Added Hermes inline reply + per-user session detail - [覺渡 #1] Race condition resolved: swap-and-drain buffer model - [覺渡 #2] Session strategy fully specified (separate pool, rolling window) - [覺渡 #3] Mention detection fires before buffer, reuses existing logic - [覺渡 #4] flush_hard_cap = 50 as safety cap - [覺渡 #5] ±20% jitter on flush interval to prevent thundering herd - [覺渡 #6] context_window semantics clarified (API fetch, not buffer) --- docs/steering/adr-ambient-mode.md | 200 ++++++++++++++++++++++-------- 1 file changed, 151 insertions(+), 49 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index 764e7a15d..da89b1cd9 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -20,7 +20,9 @@ explicit `@mention` every time. ### OpenClaw — `/activation always` -OpenClaw supports an `always` activation mode for group chats: +OpenClaw supports an `always` activation mode as a cross-platform group chat +feature (WhatsApp, Telegram, Discord, Slack, iMessage — configured via +`agents.list[].groupChat.mentionPatterns` and `channels.*.groups`): - Every message is dispatched to the agent (no mention required). - Agent returns the sentinel token `NO_REPLY` when it has nothing to add; the gateway discards silently. @@ -35,17 +37,22 @@ OpenClaw supports an `always` activation mode for group chats: Hermes provides `DISCORD_FREE_RESPONSE_CHANNELS` and `DISCORD_REQUIRE_MENTION=false`: - The bot responds to **every** message in designated channels without mention. -- History backfill (`DISCORD_HISTORY_BACKFILL`) recovers missed context when - the bot is later @mentioned. +- Free-response channels skip auto-threading (replies inline) and isolate + sessions per user (`group_sessions_per_user: true` by default). +- History backfill (`DISCORD_HISTORY_BACKFILL`) recovers missed channel context + on `@mention` — only triggered when `require_mention: true` and skipped in + free-response channels and DMs where the transcript is already complete. + Scans up to 50 messages backwards, stopping at the bot's own last message. - **Limitation:** no autonomous decision-making — the bot always replies. There is no `NO_REPLY` equivalent; it's either "respond to everything" or "respond only on mention." ### Research — "Controlling AI Agent Participation in Group Conversations" -(arXiv 2501.17258) — studies user preferences for AI agent behavior in group -settings. Key finding: users disliked agents that dominated the conversation -and preferred controls over when/how the agent participates. +(arXiv 2501.17258, Jan 2025) — studies user preferences for AI agent behavior +in group settings. Key finding: users benefited from having the AI in the group, +but disliked when the agent dominated the conversation and desired controls +over its interactive behaviors. ### Gap Our Design Fills @@ -81,7 +88,9 @@ Discord Channel │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Ambient Dispatch (batch) │ │ │ │ │ │ -│ │ • Collect buffered messages as conversation context │ │ +│ │ • Lock buffer → drain all messages → unlock immediately │ │ +│ │ (new messages enter a fresh buffer cycle) │ │ +│ │ • Prepend: channel history (context_window via API) │ │ │ │ • Prepend system instruction: │ │ │ │ "You are in ambient mode. Below is a batch of recent │ │ │ │ messages. If you have nothing valuable to add, reply │ │ @@ -99,54 +108,134 @@ Discord Channel └──────────────────────────────────────────────────────────────────┘ ``` +### Buffer Lifecycle + +The `AmbientBuffer` operates as a **swap-and-drain** model: + +1. Messages arrive → pushed into the active buffer (under a short lock). +2. Flush triggers → lock the buffer, **swap** it with a fresh empty buffer, + unlock immediately. The drained batch is processed asynchronously. +3. New messages arriving during flush processing enter the fresh buffer and + will be part of the **next** flush cycle. + +This eliminates race conditions: the lock is held only for the swap operation +(microseconds), and flush processing is fully decoupled from ingestion. + ### Flush Triggers -Messages are accumulated in a per-channel buffer and flushed when **either** +Messages are accumulated in a per-channel buffer and flushed when **any** condition is met (whichever comes first): | Trigger | Default | Description | |---------|---------|-------------| | Time | `flush_interval_seconds = 60` | Seconds since first buffered message | | Count | `flush_max_messages = 10` | Max messages to accumulate before flush | +| Hard cap | `flush_hard_cap = 50` | Safety cap — force flush regardless of timer state | +| Mention | immediate | Any message that @mentions the bot triggers instant flush | + +**Flush interval jitter:** to prevent thundering herd when many channels flush +simultaneously, the actual interval is `flush_interval_seconds ± 20%` (random +per-channel, recomputed each cycle). -**Immediate flush override:** if any message in the buffer explicitly -`@mentions` the bot, the buffer is flushed immediately (no waiting) — users -should not have to wait 60 seconds when they directly address the agent. +**Immediate flush on @mention:** when a message that `@mentions` the bot enters +the buffer, the buffer flushes immediately. However, the @mention message is +**removed from the batch** and dispatched separately via the normal +mention-triggered path (with full reactions, threading, etc.). The remaining +buffered messages are flushed as a normal ambient batch. This preserves clean +semantics: mention = normal dispatch, ambient = batch dispatch. + +### Message Filtering for Buffer + +Not all messages enter the ambient buffer: + +- ✅ **User messages** in ambient-enabled channels (without @mention) → buffer +- ✅ **Bot messages from other bots** (if `allow_bot_messages` permits) → buffer +- ❌ **Own bot messages** → never buffered (prevents echo loops) +- ❌ **Messages that @mention the bot** → bypass buffer, trigger immediate + flush of existing buffer + normal mention dispatch +- ❌ **Messages in threads created by the bot** → handled by existing + thread-based session logic, not ambient ### Batch Payload The flushed batch is formatted as a conversation block: ``` -[Ambient batch — 4 messages, channel: #general] +[Ambient context — recent channel history] +[12:00:01] UserC: I pushed the helm fix yesterday +[12:00:02] UserB: cool + +[Ambient batch — 4 new messages since last flush] +[12:03:01] UserA: Anyone know how to fix the helm release? +[12:03:04] UserB: Which chart version? +[12:03:11] UserA: 0.8.5 +[12:03:15] UserC: Try rolling back first + +[End of batch — reply only if you can add meaningful value. + Otherwise reply exactly: [NO_REPLY]] +``` -[12:00:01] UserA: Anyone know how to fix the helm release? -[12:00:04] UserB: Which chart version? -[12:00:11] UserA: 0.8.5 -[12:00:15] UserC: Try rolling back first +### Session Strategy -[End of batch — reply only if you can add value. Otherwise reply exactly: [NO_REPLY]] -``` +Ambient dispatches use a **dedicated session pool**, separate from the main +mention-triggered pool: + +| Aspect | Mention dispatch | Ambient dispatch | +|--------|-----------------|-----------------| +| Session key | `discord:` | `ambient:discord:` | +| Pool | Main pool (`[pool]`) | Ambient pool (`[pool.ambient]`) | +| Lifetime | Long-lived (session_ttl_hours) | Short-lived (ambient_session_ttl_minutes) | +| Cross-flush memory | Full transcript | Rolling window (last N flushes) | +| Reactions | ✅ Full (👀🤔🔥🆗) | ❌ Suppressed | + +**Why separate pools:** prevents ambient traffic from exhausting the main pool +and blocking normal @mention responses. The ambient pool has its own +`max_sessions` cap. + +**Cross-flush context:** the ambient session retains a rolling window of the +last `ambient_context_flushes` (default: 3) flush interactions, so the agent +has memory of what it said/declined recently. Sessions expire after +`ambient_session_ttl_minutes` (default: 60) of inactivity. ### Configuration ```toml [discord.ambient] -enabled = true -channels = ["1490282656913559673"] # channels where ambient mode is active -flush_interval_seconds = 60 # time-based flush trigger +enabled = false # master switch +channels = ["1490282656913559673"] # required — explicit allowlist, empty = disabled +flush_interval_seconds = 60 # time-based flush trigger (±20% jitter applied) flush_max_messages = 10 # count-based flush trigger -context_window = 20 # additional history before the batch +flush_hard_cap = 50 # safety cap — force flush at this count +context_window = 20 # historical messages fetched via Discord API before batch + +[pool.ambient] +max_sessions = 5 # separate pool for ambient dispatches +ambient_session_ttl_minutes = 60 # ambient session inactivity timeout +ambient_context_flushes = 3 # rolling window of retained flush history + +[ambient.limits] +max_concurrent_flushes = 3 # max simultaneous LLM calls across all ambient channels ``` -- `enabled` — master switch (default: `false`). -- `channels` — allowlist of channel IDs. Empty = all allowed channels. -- `flush_interval_seconds` — max time to hold messages before flushing - (timer starts when the first message enters an empty buffer). -- `flush_max_messages` — max messages to buffer before flushing regardless - of time elapsed. -- `context_window` — number of historical messages (before the batch) to - include for additional context. +**`channels` semantics:** an explicit allowlist is **required**. If `channels` +is empty or omitted while `enabled = true`, ambient mode is **not activated** +for any channel (fail-safe). This prevents accidental global ambient activation. + +**`context_window`:** fetches the N most recent messages from the Discord +channel history API (before the batch window) to provide additional context. +This is a Discord API call with standard rate limiting. If fewer than N messages +exist, all available messages are included. These messages are **not** counted +toward `flush_max_messages`. + +### Error Handling + +| Scenario | Behavior | +|----------|----------| +| LLM timeout / network error | Batch is **discarded** (not retried). Next flush cycle starts fresh. Logged as warning. | +| Agent returns tool calls | Treated as normal response — if final output is not `[NO_REPLY]`, post it. Tool calls execute normally within the ambient session. | +| Agent returns empty response | Treated as `[NO_REPLY]` (discard silently). | +| Buffer grows beyond `flush_hard_cap` | Force flush immediately, regardless of timer state. | +| Discord API rate limit on `context_window` fetch | Skip context window, flush batch without historical context. Log warning. | ### Sentinel Value @@ -165,23 +254,27 @@ The sentinel is `[NO_REPLY]` (case-insensitive, trimmed). Chosen because: - **Better judgment** — agent sees a complete conversational thread, making it far more likely to know when a question was already answered (→ NO_REPLY) vs. when it should contribute. -- **Natural rate limiting** — the flush interval acts as an inherent cooldown. - No separate cooldown mechanism needed. +- **Natural rate limiting** — the flush interval + jitter acts as inherent + rate-limiting. Combined with `max_concurrent_flushes`, prevents cost spikes. - **Agents behave like real team members** — aware of context, able to contribute organically. - **User-configurable** — operators decide the cost/intelligence trade-off. +- **Fail-safe defaults** — disabled by default, requires explicit channel list, + separate session pool prevents impact on normal operations. ### Trade-offs - **Latency** — ambient replies are delayed by up to `flush_interval_seconds`. Acceptable because ambient replies are unsolicited; explicitly mentioned - messages bypass the buffer and get immediate dispatch via the normal path. + messages bypass the buffer entirely via the normal dispatch path. - **Token cost still increases** — even with batching, each flush is an LLM - call. Mitigations: per-channel opt-in, tunable flush interval/count. + call. Mitigations: per-channel opt-in, tunable flush interval/count, + `max_concurrent_flushes` cap. - **Potential for noise** — a poorly-tuned prompt or model may reply too eagerly. The batch format and explicit instructions mitigate this. -- **Session management** — ambient dispatches should use short-lived or - stateless sessions so the pool isn't exhausted. +- **No retry on failure** — ambient batches are fire-and-forget. If a flush + fails, those messages are lost context. Acceptable because ambient is + best-effort by nature. ## Alternatives Considered @@ -202,16 +295,25 @@ The sentinel is `[NO_REPLY]` (case-insensitive, trimmed). Chosen because: ## Implementation Notes -- A new `AmbientBuffer` struct per channel holds pending messages and a - flush timer. On flush, it formats the batch and sends to the agent via - the existing ACP dispatch path. -- The `[NO_REPLY]` check should be applied in the response router - (`src/adapter.rs`) before calling `send_message`. -- Reactions (👀, 🤔, etc.) should be suppressed for ambient dispatches to - avoid spamming the channel with status indicators. -- When a message in the buffer contains an explicit `@mention` of the bot, - the buffer should flush immediately and the dispatch should be marked as - "mention-triggered" (not ambient) so normal reply behavior applies. -- The `allow_bot_messages` and `allow_user_messages` config options already - provide dispatch filtering logic in `src/discord.rs`. Ambient mode adds a - new dispatch path that coexists with the existing mention-based path. +- **Buffer concurrency:** use `tokio::sync::Mutex>` with + swap-and-drain on flush. Lock is held only for the swap (O(1)), never during + LLM processing. +- **Flush timer:** per-channel `tokio::spawn` with `sleep(interval ± jitter)`. + Timer resets when buffer transitions from empty to non-empty. +- **`[NO_REPLY]` check:** applied in the response router (`src/adapter.rs`) + after `stream_prompt` completes, before calling `send_message`. If the + trimmed final content equals `[NO_REPLY]` (case-insensitive), delete the + thinking message and return early. +- **Mention detection reuse:** the existing `is_mentioned` logic in + `Handler::message()` (src/discord.rs) fires **before** the buffer push. + If mentioned, the message takes the normal dispatch path; remaining buffer + is flushed as ambient. +- **Bot echo prevention:** `msg.author.id == bot_id` check (already exists in + Handler::message) ensures bot's own messages never enter the buffer. +- **Reactions suppressed:** ambient dispatches skip `StatusReactionController` + entirely — no 👀🤔🔥 on every channel message. +- **Serialization with normal dispatch:** the ambient session key + (`ambient:discord:`) is different from mention session keys + (`discord:`), so they never contend on the same session lock. + If a normal @mention arrives while an ambient flush is in-flight, both + proceed independently (different sessions, different pools). From d9693ff3ed8d8554a5a660daeafbc13a891ba6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 13:38:08 +0000 Subject: [PATCH 04/13] docs: integrate with existing Dispatcher/turn-boundary batching infra Ambient Mode reuses the Dispatcher, BufferedMessage, consumer_loop, and pack_arrival_event infrastructure from PR #686 (message_processing_mode). Key difference: ambient consumer uses timer-based flush instead of turn-boundary drain. --- docs/steering/adr-ambient-mode.md | 87 ++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index da89b1cd9..b1dd47428 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -295,13 +295,86 @@ The sentinel is `[NO_REPLY]` (case-insensitive, trimmed). Chosen because: ## Implementation Notes -- **Buffer concurrency:** use `tokio::sync::Mutex>` with - swap-and-drain on flush. Lock is held only for the swap (O(1)), never during - LLM processing. -- **Flush timer:** per-channel `tokio::spawn` with `sleep(interval ± jitter)`. - Timer resets when buffer transitions from empty to non-empty. -- **`[NO_REPLY]` check:** applied in the response router (`src/adapter.rs`) - after `stream_prompt` completes, before calling `send_message`. If the +### Reuse of Existing `Dispatcher` Infrastructure + +OpenAB already has a **turn-boundary batching** system (PR #686, +`message_processing_mode` config) with `Dispatcher`, per-thread `mpsc::channel`, +and `consumer_loop`. Ambient Mode should extend this infrastructure rather than +building a parallel buffer system. + +**What we reuse:** +- `Dispatcher::submit()` — message ingestion into bounded mpsc channel +- `BufferedMessage` struct — carries prompt, sender_context, attachments +- `consumer_loop` — long-lived task that drains and dispatches +- `dispatch_batch` → `pack_arrival_event` — packing N messages into + `Vec` with repeated `` delimiters +- `ThreadHandle` lifecycle — idle eviction, SendError retry + +**What differs for ambient mode:** + +| Aspect | Turn-boundary (existing) | Ambient consumer | +|--------|-------------------------|-----------------| +| Drain trigger | Turn completion (greedy drain when agent finishes) | Timer (`flush_interval ± jitter`) OR count (`flush_max_messages`) | +| Key | `(platform, thread_id)` | `ambient:(platform, channel_id)` | +| Prerequisite | Message already passed mention/involved gate | Message has NO mention (new gate path) | +| Response handling | Normal post | `[NO_REPLY]` check before posting | +| Reactions | Full (👀🤔🔥🆗) | Suppressed | +| Session pool | Main pool | Ambient pool (separate `max_sessions`) | + +**New `message_processing_mode` value:** extend the enum to include `"ambient"`: + +```toml +# Existing modes (unchanged): +message_processing_mode = "per-message" # 1 msg → 1 turn +message_processing_mode = "per-thread" # batch at turn boundary +message_processing_mode = "per-lane" # batch at turn boundary, per-sender + +# New mode (this ADR): +# Configured separately in [discord.ambient] — not via message_processing_mode. +# Ambient is a parallel dispatch path, not a replacement for the primary mode. +``` + +Ambient mode runs as a **separate Dispatcher instance** alongside the primary +one. The primary Dispatcher handles mention-triggered messages (using whatever +`message_processing_mode` is configured). The ambient Dispatcher handles +non-mentioned messages in ambient-enabled channels with a timer-based consumer. + +### Ambient Consumer Loop + +```rust +// Pseudocode — ambient consumer differs from turn-boundary consumer: +async fn ambient_consumer_loop(rx, config) { + loop { + let first = rx.recv().await; // park until first msg + let deadline = Instant::now() + config.flush_interval_jittered(); + let mut batch = vec![first]; + + loop { + let remaining = deadline - Instant::now(); + match timeout(remaining, rx.recv()).await { + Ok(Some(msg)) => { + batch.push(msg); + if batch.len() >= config.flush_max_messages { break; } + if batch.len() >= config.flush_hard_cap { break; } + } + Ok(None) => break, // channel closed + Err(_) => break, // timer expired + } + } + + // Flush: dispatch batch with [NO_REPLY] system prompt + let response = dispatch_ambient_batch(batch).await; + if response.trim().eq_ignore_ascii_case("[NO_REPLY]") { + continue; // discard silently + } + post_response(response).await; + } +} +``` + +### Other Implementation Details + +- **`[NO_REPLY]` check:** applied after `stream_prompt` completes. If the trimmed final content equals `[NO_REPLY]` (case-insensitive), delete the thinking message and return early. - **Mention detection reuse:** the existing `is_mentioned` logic in From 64a80c16c37fc6ddb5818f5268170271ba2cade2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 13:39:02 +0000 Subject: [PATCH 05/13] docs: add semaphore + error handling to ambient consumer pseudocode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [覺渡 R2#1] flush_semaphore.acquire() before dispatch [覺渡 R2#2] match on dispatch result, warn + discard on error --- docs/steering/adr-ambient-mode.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index b1dd47428..aef390c0c 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -343,7 +343,7 @@ non-mentioned messages in ambient-enabled channels with a timer-based consumer. ```rust // Pseudocode — ambient consumer differs from turn-boundary consumer: -async fn ambient_consumer_loop(rx, config) { +async fn ambient_consumer_loop(rx, config, flush_semaphore) { loop { let first = rx.recv().await; // park until first msg let deadline = Instant::now() + config.flush_interval_jittered(); @@ -362,12 +362,22 @@ async fn ambient_consumer_loop(rx, config) { } } + // Acquire global concurrency permit (blocks if max_concurrent_flushes reached) + let _permit = flush_semaphore.acquire().await; + // Flush: dispatch batch with [NO_REPLY] system prompt - let response = dispatch_ambient_batch(batch).await; - if response.trim().eq_ignore_ascii_case("[NO_REPLY]") { - continue; // discard silently + match dispatch_ambient_batch(batch).await { + Ok(response) => { + if !response.trim().eq_ignore_ascii_case("[NO_REPLY]") { + post_response(response).await; + } + } + Err(e) => { + warn!("ambient flush failed, discarding batch: {e}"); + // Batch is discarded — next cycle starts fresh (fire-and-forget) + } } - post_response(response).await; + // _permit dropped here — releases semaphore slot } } ``` From f7823031c560d7435b101f7fb9deb7530c265b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 13:40:35 +0000 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20address=20=E6=93=BA=E6=B8=A1=20R2?= =?UTF-8?q?=20findings=20=E2=80=94=20race=20condition,=20thinking=20msg,?= =?UTF-8?q?=20bot=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [擺渡 #1] Add concurrent reply prevention via per-channel flushing flag [擺渡 #2] Ambient dispatches post directly, no thinking placeholder [擺渡 #3] Handle None on first rx.recv() (channel closed → exit) [擺渡 #4] Bot loop prevention: MAX_CONSECUTIVE_BOT_TURNS + prompt instruction --- docs/steering/adr-ambient-mode.md | 43 ++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index aef390c0c..523853a4d 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -144,6 +144,19 @@ mention-triggered path (with full reactions, threading, etc.). The remaining buffered messages are flushed as a normal ambient batch. This preserves clean semantics: mention = normal dispatch, ambient = batch dispatch. +**Concurrent reply prevention:** to prevent double-replying to the same channel +when a @mention arrives during ambient processing: +- The ambient consumer holds a per-channel `AtomicBool` lock (`flushing`). +- Normal mention dispatch checks this flag: if the ambient consumer is + mid-flush, the mention dispatch waits for the ambient flush to complete + (or cancel) before proceeding. +- Conversely, if a mention dispatch is already in-flight on the same channel + (tracked via the primary Dispatcher), the ambient consumer skips posting + its response even if it's not `[NO_REPLY]` — the user already got a direct + reply. +- This ensures at most one bot response is posted to a channel at any given + moment from the ambient + mention paths combined. + ### Message Filtering for Buffer Not all messages enter the ambient buffer: @@ -343,9 +356,12 @@ non-mentioned messages in ambient-enabled channels with a timer-based consumer. ```rust // Pseudocode — ambient consumer differs from turn-boundary consumer: -async fn ambient_consumer_loop(rx, config, flush_semaphore) { +async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { loop { - let first = rx.recv().await; // park until first msg + let first = match rx.recv().await { + Some(msg) => msg, + None => return, // channel closed, exit consumer + }; let deadline = Instant::now() + config.flush_interval_jittered(); let mut batch = vec![first]; @@ -365,18 +381,22 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore) { // Acquire global concurrency permit (blocks if max_concurrent_flushes reached) let _permit = flush_semaphore.acquire().await; + // Mark channel as flushing (prevents concurrent mention reply) + channel_flushing.store(true, Ordering::Release); + // Flush: dispatch batch with [NO_REPLY] system prompt match dispatch_ambient_batch(batch).await { Ok(response) => { if !response.trim().eq_ignore_ascii_case("[NO_REPLY]") { - post_response(response).await; + post_response(response).await; // no "thinking" msg — post directly } } Err(e) => { warn!("ambient flush failed, discarding batch: {e}"); - // Batch is discarded — next cycle starts fresh (fire-and-forget) } } + + channel_flushing.store(false, Ordering::Release); // _permit dropped here — releases semaphore slot } } @@ -384,9 +404,20 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore) { ### Other Implementation Details +- **No thinking message:** ambient dispatches do NOT send a "..." placeholder + message. Unlike normal mention dispatch, ambient responses are posted directly + as a single message (or discarded). This eliminates visual flickering in + the channel. - **`[NO_REPLY]` check:** applied after `stream_prompt` completes. If the - trimmed final content equals `[NO_REPLY]` (case-insensitive), delete the - thinking message and return early. + trimmed final content equals `[NO_REPLY]` (case-insensitive), no message is + posted. +- **Bot-to-bot loop prevention:** the bot's own messages never enter the buffer + (existing `bot_id` check). Additionally, messages from other bots are gated + by `allow_bot_messages` config. Even if another bot's reply enters the buffer, + the existing `MAX_CONSECUTIVE_BOT_TURNS` hard cap (applied at ingest, before + `submit`) prevents infinite loops. The ambient system prompt also explicitly + instructs: "Do not reply to other bot messages unless directly relevant to a + human's question." - **Mention detection reuse:** the existing `is_mentioned` logic in `Handler::message()` (src/discord.rs) fires **before** the buffer push. If mentioned, the message takes the normal dispatch path; remaining buffer From ea291c4340a7bc59e86d26df98fb6c4e83649177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 13:41:19 +0000 Subject: [PATCH 07/13] docs: strengthen F1 (discard buffer on mention) + F4 (bot msgs off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [擺渡 F1] @mention discards ambient buffer instead of flushing it; cancel in-flight ambient on mention arrival [擺渡 F4] allow_bot_messages defaults to 'off' for ambient channels --- docs/steering/adr-ambient-mode.md | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index 523853a4d..abc7f0aaa 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -138,22 +138,21 @@ simultaneously, the actual interval is `flush_interval_seconds ± 20%` (random per-channel, recomputed each cycle). **Immediate flush on @mention:** when a message that `@mentions` the bot enters -the buffer, the buffer flushes immediately. However, the @mention message is -**removed from the batch** and dispatched separately via the normal -mention-triggered path (with full reactions, threading, etc.). The remaining -buffered messages are flushed as a normal ambient batch. This preserves clean -semantics: mention = normal dispatch, ambient = batch dispatch. - -**Concurrent reply prevention:** to prevent double-replying to the same channel -when a @mention arrives during ambient processing: +the buffer, the ambient buffer for that channel is **discarded** (not flushed). +The @mention message is dispatched via the normal mention-triggered path (with +full reactions, threading, etc.). Rationale: the bot is about to reply directly +via mention — flushing the ambient buffer simultaneously would produce a +redundant double-reply. + +**Concurrent reply prevention:** to prevent double-replying to the same channel: - The ambient consumer holds a per-channel `AtomicBool` lock (`flushing`). -- Normal mention dispatch checks this flag: if the ambient consumer is - mid-flush, the mention dispatch waits for the ambient flush to complete - (or cancel) before proceeding. -- Conversely, if a mention dispatch is already in-flight on the same channel - (tracked via the primary Dispatcher), the ambient consumer skips posting - its response even if it's not `[NO_REPLY]` — the user already got a direct - reply. +- When a @mention arrives on an ambient channel, the system: + 1. If ambient consumer is idle (not flushing): discard buffer, dispatch + mention normally. + 2. If ambient consumer is mid-flush: cancel/ignore the ambient response + (set a `cancelled` flag checked before posting), then dispatch mention. +- Conversely, if a mention dispatch is already in-flight, the ambient consumer + skips posting its response — the user already got a direct reply. - This ensures at most one bot response is posted to a channel at any given moment from the ambient + mention paths combined. @@ -412,12 +411,13 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { trimmed final content equals `[NO_REPLY]` (case-insensitive), no message is posted. - **Bot-to-bot loop prevention:** the bot's own messages never enter the buffer - (existing `bot_id` check). Additionally, messages from other bots are gated - by `allow_bot_messages` config. Even if another bot's reply enters the buffer, - the existing `MAX_CONSECUTIVE_BOT_TURNS` hard cap (applied at ingest, before - `submit`) prevents infinite loops. The ambient system prompt also explicitly - instructs: "Do not reply to other bot messages unless directly relevant to a - human's question." + (existing `bot_id` check). **For ambient channels, `allow_bot_messages` + defaults to `"off"`** regardless of the global setting — other bots' messages + are excluded from the ambient buffer unless the operator explicitly opts in. + Even if opted in, the existing `MAX_CONSECUTIVE_BOT_TURNS` hard cap (applied + at ingest, before `submit`) prevents infinite loops. The ambient system prompt + also explicitly instructs: "Do not reply to other bot messages unless directly + relevant to a human's question." - **Mention detection reuse:** the existing `is_mentioned` logic in `Handler::message()` (src/discord.rs) fires **before** the buffer push. If mentioned, the message takes the normal dispatch path; remaining buffer From 77adaed584847dd9e0a9b63ef57f6fa2f9c67d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= <超渡法師@openab.dev> Date: Fri, 26 Jun 2026 14:22:04 +0000 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20fix=20wording=20inconsistency=20?= =?UTF-8?q?=E2=80=94=20mention=20discards=20buffer,=20not=20flushes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [口渡 R3#1] Unify Message Filtering wording with Immediate flush section --- docs/steering/adr-ambient-mode.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index abc7f0aaa..adbeb9093 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -163,8 +163,8 @@ Not all messages enter the ambient buffer: - ✅ **User messages** in ambient-enabled channels (without @mention) → buffer - ✅ **Bot messages from other bots** (if `allow_bot_messages` permits) → buffer - ❌ **Own bot messages** → never buffered (prevents echo loops) -- ❌ **Messages that @mention the bot** → bypass buffer, trigger immediate - flush of existing buffer + normal mention dispatch +- ❌ **Messages that @mention the bot** → bypass buffer, discard existing + buffer contents + normal mention dispatch - ❌ **Messages in threads created by the bot** → handled by existing thread-based session logic, not ambient From edf1ecaffe7dd0b4855c1649021607116b38919c Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 26 Jun 2026 23:13:50 +0000 Subject: [PATCH 09/13] docs(adr): add detailed ASCII architecture diagrams for ambient mode - Architecture Overview: full message routing decision tree - Dual-Path Concurrency: timeline showing mention vs ambient interaction - Shows buffer lifecycle, flush phases, and response routing --- docs/steering/adr-ambient-mode.md | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index adbeb9093..e1a032556 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -66,6 +66,119 @@ message. Our design accumulates messages and flushes them as a batch, which: Introduce an **Ambient Mode** using a **batch flush** strategy. +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Discord Gateway (events) │ +└───────────────────────────────────┬─────────────────────────────────────────┘ + │ MESSAGE_CREATE + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Handler::message() │ +│ │ +│ ┌─────────────┐ YES ┌──────────────────────────────────────────┐ │ +│ │ Own bot msg?├──────────►│ DROP (echo prevention) │ │ +│ └──────┬──────┘ └──────────────────────────────────────────┘ │ +│ │ NO │ +│ ▼ │ +│ ┌─────────────┐ YES ┌──────────────────────────────────────────┐ │ +│ │ @mention? ├──────────►│ Normal Mention Dispatch Path │ │ +│ │ │ │ • Discard ambient buffer for this channel│ │ +│ └──────┬──────┘ │ • Cancel in-flight ambient flush │ │ +│ │ NO │ • Thread + reactions + full session │ │ +│ ▼ └──────────────────────────────────────────┘ │ +│ ┌──────────────────┐ NO ┌──────────────────────────────────────────┐ │ +│ │ Ambient channel? ├────►│ IGNORE (not ambient-enabled) │ │ +│ └──────┬────────────┘ └──────────────────────────────────────────┘ │ +│ │ YES │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Ambient Dispatcher::submit(msg) │ │ +│ └──────────────────────────────┬───────────────────────────────────┘ │ +└─────────────────────────────────┼───────────────────────────────────────────┘ + │ mpsc::channel (bounded) + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Ambient Consumer Loop (per-channel) │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Buffer Phase │ │ +│ │ │ │ +│ │ msg ──► [ buffer ] ◄── accumulate until trigger │ │ +│ │ │ │ +│ │ Flush triggers (whichever first): │ │ +│ │ • Timer expired (flush_interval ± 20% jitter) │ │ +│ │ • Count reached (flush_max_messages = 10) │ │ +│ │ • Hard cap hit (flush_hard_cap = 50) │ │ +│ └───────────────────────────────┬───────────────────────────────────────┘ │ +│ │ swap-and-drain │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Flush Phase │ │ +│ │ │ │ +│ │ 1. Acquire global semaphore (max_concurrent_flushes = 3) │ │ +│ │ 2. Set channel_flushing = true │ │ +│ │ 3. Fetch context_window (20 msgs) from Discord API │ │ +│ │ 4. Build payload: │ │ +│ │ ┌──────────────────────────────────────────────────────┐ │ │ +│ │ │ System: "You are in ambient mode..." │ │ │ +│ │ │ [Ambient context — channel history] │ │ │ +│ │ │ [Ambient batch — N new messages] │ │ │ +│ │ │ [End of batch — reply [NO_REPLY] if nothing to add] │ │ │ +│ │ └──────────────────────────────────────────────────────┘ │ │ +│ │ 5. Send to LLM (ambient session pool) │ │ +│ │ 6. Set channel_flushing = false │ │ +│ │ 7. Release semaphore │ │ +│ └───────────────────────────────┬───────────────────────────────────────┘ │ +└──────────────────────────────────┼──────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Response Router │ +│ │ +│ LLM Response ──► trim + lowercase │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ ▼ ▼ │ +│ "[no_reply]" Actual content │ +│ │ │ │ +│ ▼ ▼ │ +│ 🗑️ Discard silently 📤 Post to channel │ +│ (no reactions, (no 👀🤔🔥 reactions, │ +│ no threading) direct message post) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Dual-Path Concurrency (Mention vs Ambient) + +``` +Channel #general (ambient-enabled) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Timeline: + t=0s UserA: "how do I deploy?" ──► ambient buffer + t=3s UserB: "check the wiki" ──► ambient buffer + t=8s UserA: "wiki is outdated" ──► ambient buffer + t=15s UserC: "@bot help me deploy" ──► MENTION PATH + │ + ┌─────────────────────────────────────┘ + ▼ + ┌────────────────────────────────┐ ┌────────────────────────────┐ + │ Mention Dispatch │ │ Ambient Buffer │ + │ │ │ │ + │ Session: discord: │ │ [msg1, msg2, msg3] │ + │ Pool: Main │ │ │ │ + │ Reactions: 👀🤔🔥🆗 │ │ ▼ │ + │ Response: in new thread │ │ DISCARDED (not flushed) │ + └────────────────────────────────┘ └────────────────────────────┘ + + t=16s (new buffer cycle starts fresh) + t=20s UserD: "anyone tried helm 3.15?" ──► new ambient buffer + ... + t=76s flush_interval elapsed ──► flush new buffer +``` + ### Mechanism ``` From 1b0736dd57fbb878625dedb8e3ba1b68366bad93 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 26 Jun 2026 23:20:33 +0000 Subject: [PATCH 10/13] docs(adr): unify ambient config under top-level [ambient] section - Group all ambient settings under [ambient] instead of scattered [discord.ambient], [pool.ambient], [ambient.limits] - Platform-specific config in [ambient.discord] (extensible to slack/telegram) - Simplify pool key names: remove redundant ambient_ prefix (session_ttl_minutes, context_flushes) --- docs/steering/adr-ambient-mode.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/steering/adr-ambient-mode.md b/docs/steering/adr-ambient-mode.md index e1a032556..fb3a79202 100644 --- a/docs/steering/adr-ambient-mode.md +++ b/docs/steering/adr-ambient-mode.md @@ -324,24 +324,32 @@ has memory of what it said/declined recently. Sessions expire after ### Configuration +All ambient settings are grouped under a top-level `[ambient]` section: + ```toml -[discord.ambient] +[ambient] enabled = false # master switch -channels = ["1490282656913559673"] # required — explicit allowlist, empty = disabled flush_interval_seconds = 60 # time-based flush trigger (±20% jitter applied) flush_max_messages = 10 # count-based flush trigger flush_hard_cap = 50 # safety cap — force flush at this count context_window = 20 # historical messages fetched via Discord API before batch +max_concurrent_flushes = 3 # max simultaneous LLM calls across all ambient channels -[pool.ambient] +[ambient.pool] max_sessions = 5 # separate pool for ambient dispatches -ambient_session_ttl_minutes = 60 # ambient session inactivity timeout -ambient_context_flushes = 3 # rolling window of retained flush history +session_ttl_minutes = 60 # ambient session inactivity timeout +context_flushes = 3 # rolling window of retained flush history -[ambient.limits] -max_concurrent_flushes = 3 # max simultaneous LLM calls across all ambient channels +[ambient.discord] +channels = ["1490282656913559673"] # required — explicit allowlist, empty = disabled ``` +**Design rationale:** flush parameters (`flush_interval_seconds`, `flush_max_messages`, +etc.) are platform-agnostic and live at the `[ambient]` level. Platform-specific +settings (channel allowlists) live under `[ambient.]`. This allows +future multi-platform support (`[ambient.slack]`, `[ambient.telegram]`) without +restructuring. + **`channels` semantics:** an explicit allowlist is **required**. If `channels` is empty or omitted while `enabled = true`, ambient mode is **not activated** for any channel (fail-safe). This prevents accidental global ambient activation. From c34ce74e1c44428bc90f62c0a6e9a400caa91274 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 26 Jun 2026 23:20:44 +0000 Subject: [PATCH 11/13] docs(adr): move ambient mode ADR to docs/adr/ambient.md Addresses external reviewer feedback from @thepagent and team consensus. docs/steering/ is for process guides; docs/adr/ is for ADRs. --- docs/{steering/adr-ambient-mode.md => adr/ambient.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{steering/adr-ambient-mode.md => adr/ambient.md} (100%) diff --git a/docs/steering/adr-ambient-mode.md b/docs/adr/ambient.md similarity index 100% rename from docs/steering/adr-ambient-mode.md rename to docs/adr/ambient.md From 12c9c89957baa3e9e5d759b6b6775aa395f48a50 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 26 Jun 2026 23:22:48 +0000 Subject: [PATCH 12/13] =?UTF-8?q?docs(adr):=20address=20review=20findings?= =?UTF-8?q?=20from=20=E6=B3=95=E5=B8=AB=E5=9C=98=E9=9A=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: - Unify @mention handling path (discard buffer, not flush) — resolves 3 contradictions across flush triggers table, concurrent reply section, and implementation details - Replace non-existent MAX_CONSECUTIVE_BOT_TURNS with actual max_bot_turns - Explain flush_hard_cap vs flush_max_messages relationship - Add post_guard for atomic check-and-post (TOCTOU race fix) - Add flush_timeout_seconds for AtomicBool safety recovery - Reconcile Buffer Lifecycle (conceptual) with mpsc channel (implementation) - Remove contradictory 'extend enum' wording from message_processing_mode - Add allow_bot_messages to [ambient.discord] config - Simplify pool key names (drop ambient_ prefix) --- docs/adr/ambient.md | 110 +++++++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/docs/adr/ambient.md b/docs/adr/ambient.md index fb3a79202..28d22befd 100644 --- a/docs/adr/ambient.md +++ b/docs/adr/ambient.md @@ -201,8 +201,8 @@ Discord Channel │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Ambient Dispatch (batch) │ │ │ │ │ │ -│ │ • Lock buffer → drain all messages → unlock immediately │ │ -│ │ (new messages enter a fresh buffer cycle) │ │ +│ │ • Drain messages from mpsc channel into batch │ │ +│ │ (new messages queue for next flush cycle) │ │ │ │ • Prepend: channel history (context_window via API) │ │ │ │ • Prepend system instruction: │ │ │ │ "You are in ambient mode. Below is a batch of recent │ │ @@ -223,16 +223,18 @@ Discord Channel ### Buffer Lifecycle -The `AmbientBuffer` operates as a **swap-and-drain** model: +Conceptually, the ambient buffer operates as a **swap-and-drain** model — +ingestion is decoupled from flush processing so neither blocks the other. -1. Messages arrive → pushed into the active buffer (under a short lock). -2. Flush triggers → lock the buffer, **swap** it with a fresh empty buffer, - unlock immediately. The drained batch is processed asynchronously. -3. New messages arriving during flush processing enter the fresh buffer and - will be part of the **next** flush cycle. +In practice, this is implemented via the existing `Dispatcher` infrastructure +using a bounded `mpsc::channel` per ambient channel (see Implementation Notes). +The mpsc channel provides the same decoupling guarantees: `submit()` pushes +messages into the channel (non-blocking for senders), and the ambient consumer +loop drains messages via `rx.recv()` with a deadline. The "swap" happens +implicitly — once the consumer drains and moves into flush processing, new +messages queue in the channel buffer for the next cycle. -This eliminates race conditions: the lock is held only for the swap operation -(microseconds), and flush processing is fully decoupled from ingestion. +The key invariant: flush processing never blocks message ingestion. ### Flush Triggers @@ -243,27 +245,48 @@ condition is met (whichever comes first): |---------|---------|-------------| | Time | `flush_interval_seconds = 60` | Seconds since first buffered message | | Count | `flush_max_messages = 10` | Max messages to accumulate before flush | -| Hard cap | `flush_hard_cap = 50` | Safety cap — force flush regardless of timer state | -| Mention | immediate | Any message that @mentions the bot triggers instant flush | +| Hard cap | `flush_hard_cap = 50` | Safety cap — force flush if `flush_max_messages` is set high or disabled | + +**Relationship between `flush_max_messages` and `flush_hard_cap`:** +`flush_max_messages` is the operational trigger (default 10). `flush_hard_cap` +is a safety net for deployments where operators set `flush_max_messages` to a +very large value (or 0 to disable count-based flushing) to rely solely on +timer-based flushes. In that scenario, `flush_hard_cap` prevents unbounded +buffer growth during message spikes. With default values (10 and 50), the hard +cap is effectively dormant — this is intentional. +| Mention | immediate | @mention discards buffer, triggers normal mention dispatch (not a flush) | **Flush interval jitter:** to prevent thundering herd when many channels flush simultaneously, the actual interval is `flush_interval_seconds ± 20%` (random per-channel, recomputed each cycle). -**Immediate flush on @mention:** when a message that `@mentions` the bot enters -the buffer, the ambient buffer for that channel is **discarded** (not flushed). -The @mention message is dispatched via the normal mention-triggered path (with -full reactions, threading, etc.). Rationale: the bot is about to reply directly -via mention — flushing the ambient buffer simultaneously would produce a -redundant double-reply. +**@mention handling:** when a `@mention` is detected at the Handler level +(before the buffer), the system: +1. Discards the ambient buffer for that channel (messages are lost — not flushed). +2. If an ambient flush is already in-flight, sets a `cancelled` flag to suppress + the ambient response before posting (see Concurrent reply prevention below). +3. Dispatches the @mention via the normal mention-triggered path (with full + reactions, threading, etc.). + +Rationale: the bot is about to reply directly via mention — flushing the ambient +buffer simultaneously would produce a redundant double-reply. The discarded +messages provided conversational context that the mention dispatch can retrieve +via `context_window` (Discord API history fetch) if needed. **Concurrent reply prevention:** to prevent double-replying to the same channel: -- The ambient consumer holds a per-channel `AtomicBool` lock (`flushing`). -- When a @mention arrives on an ambient channel, the system: +- The ambient consumer holds a per-channel `AtomicBool` flag (`flushing`) with + a **safety timeout** (`flush_timeout_seconds = 120`). If the flag remains set + beyond the timeout (e.g., consumer panicked/OOM), it is automatically reset + and the condition is logged as an error. +- When a @mention arrives on an ambient channel (detected at Handler level, + before the buffer), the system: 1. If ambient consumer is idle (not flushing): discard buffer, dispatch mention normally. - 2. If ambient consumer is mid-flush: cancel/ignore the ambient response - (set a `cancelled` flag checked before posting), then dispatch mention. + 2. If ambient consumer is mid-flush: set a `cancelled` flag **and** suppress + the ambient response atomically using a `post_guard` — the guard is + acquired before the `[NO_REPLY]` check and held through `post_response()`. + The mention dispatch path invalidates the guard, ensuring no ambient + message is posted after cancellation. - Conversely, if a mention dispatch is already in-flight, the ambient consumer skips posting its response — the user already got a direct reply. - This ensures at most one bot response is posted to a channel at any given @@ -334,6 +357,7 @@ flush_max_messages = 10 # count-based flush trigger flush_hard_cap = 50 # safety cap — force flush at this count context_window = 20 # historical messages fetched via Discord API before batch max_concurrent_flushes = 3 # max simultaneous LLM calls across all ambient channels +flush_timeout_seconds = 120 # safety timeout — auto-reset flushing flag if exceeded [ambient.pool] max_sessions = 5 # separate pool for ambient dispatches @@ -342,6 +366,7 @@ context_flushes = 3 # rolling window of retained flush history [ambient.discord] channels = ["1490282656913559673"] # required — explicit allowlist, empty = disabled +allow_bot_messages = false # whether other bots' messages enter ambient buffer ``` **Design rationale:** flush parameters (`flush_interval_seconds`, `flush_max_messages`, @@ -454,17 +479,19 @@ building a parallel buffer system. | Reactions | Full (👀🤔🔥🆗) | Suppressed | | Session pool | Main pool | Ambient pool (separate `max_sessions`) | -**New `message_processing_mode` value:** extend the enum to include `"ambient"`: +**Not a new `message_processing_mode`:** ambient mode is a **parallel dispatch +path**, not a replacement for the primary processing mode. The existing +`message_processing_mode` enum (`per-message`, `per-thread`, `per-lane`) is +unchanged. Ambient mode is configured separately via `[ambient]` and runs as +a separate Dispatcher instance alongside the primary one. ```toml -# Existing modes (unchanged): +# Existing modes (unchanged) — applies to mention-triggered messages: message_processing_mode = "per-message" # 1 msg → 1 turn message_processing_mode = "per-thread" # batch at turn boundary message_processing_mode = "per-lane" # batch at turn boundary, per-sender -# New mode (this ADR): -# Configured separately in [discord.ambient] — not via message_processing_mode. -# Ambient is a parallel dispatch path, not a replacement for the primary mode. +# Ambient mode is independent — configured in [ambient], not here. ``` Ambient mode runs as a **separate Dispatcher instance** alongside the primary @@ -501,14 +528,22 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { // Acquire global concurrency permit (blocks if max_concurrent_flushes reached) let _permit = flush_semaphore.acquire().await; - // Mark channel as flushing (prevents concurrent mention reply) - channel_flushing.store(true, Ordering::Release); + // Mark channel as flushing (with safety timeout for auto-reset) + let _flushing_guard = FlushingGuard::new( + channel_flushing, + config.flush_timeout_seconds, + ); // auto-resets on drop OR timeout // Flush: dispatch batch with [NO_REPLY] system prompt match dispatch_ambient_batch(batch).await { Ok(response) => { if !response.trim().eq_ignore_ascii_case("[NO_REPLY]") { - post_response(response).await; // no "thinking" msg — post directly + // Atomic check-and-post: acquire post_guard to prevent + // race with mention cancellation + if let Some(_guard) = post_guard.try_acquire() { + post_response(response).await; + } + // else: mention arrived, cancelled — skip posting } } Err(e) => { @@ -516,7 +551,7 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { } } - channel_flushing.store(false, Ordering::Release); + // _flushing_guard dropped here — resets channel_flushing // _permit dropped here — releases semaphore slot } } @@ -535,14 +570,14 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { (existing `bot_id` check). **For ambient channels, `allow_bot_messages` defaults to `"off"`** regardless of the global setting — other bots' messages are excluded from the ambient buffer unless the operator explicitly opts in. - Even if opted in, the existing `MAX_CONSECUTIVE_BOT_TURNS` hard cap (applied - at ingest, before `submit`) prevents infinite loops. The ambient system prompt + Even if opted in, the existing `max_bot_turns` config (default 100, hard + cap 1000 — applied at ingest, before `submit`) prevents infinite loops. The ambient system prompt also explicitly instructs: "Do not reply to other bot messages unless directly relevant to a human's question." - **Mention detection reuse:** the existing `is_mentioned` logic in `Handler::message()` (src/discord.rs) fires **before** the buffer push. - If mentioned, the message takes the normal dispatch path; remaining buffer - is flushed as ambient. + If mentioned, the message takes the normal dispatch path; the ambient buffer + for that channel is discarded (not flushed). - **Bot echo prevention:** `msg.author.id == bot_id` check (already exists in Handler::message) ensures bot's own messages never enter the buffer. - **Reactions suppressed:** ambient dispatches skip `StatusReactionController` @@ -550,5 +585,6 @@ async fn ambient_consumer_loop(rx, config, flush_semaphore, channel_flushing) { - **Serialization with normal dispatch:** the ambient session key (`ambient:discord:`) is different from mention session keys (`discord:`), so they never contend on the same session lock. - If a normal @mention arrives while an ambient flush is in-flight, both - proceed independently (different sessions, different pools). + However, concurrent reply prevention (see above) ensures at most one response + is posted per channel — if a @mention arrives mid-flush, the ambient response + is cancelled via the `post_guard` mechanism. From 827896fae0f4aa7d3239f5131afbe07cfbecd031 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 26 Jun 2026 23:23:05 +0000 Subject: [PATCH 13/13] docs(adr): fix author attribution to chaodu-agent --- docs/adr/ambient.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/ambient.md b/docs/adr/ambient.md index 28d22befd..dfece063e 100644 --- a/docs/adr/ambient.md +++ b/docs/adr/ambient.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-06-26 -- **Author:** Pahud Hsieh +- **Author:** chaodu-agent ## Context