Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
631 changes: 631 additions & 0 deletions crates/openab-core/src/ambient.rs

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ pub struct Config {
pub workspace: WorkspaceConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub ambient: AmbientConfig,
}

#[derive(Debug, Clone, Default, Deserialize)]
Expand Down Expand Up @@ -1223,6 +1225,130 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result<Config> {
Ok(config)
}

// ---------------------------------------------------------------------------
// Ambient Mode configuration
// ---------------------------------------------------------------------------

/// Top-level `[ambient]` configuration for passive channel listening.
///
/// NOTE: ADR #1211 originally specified `[discord.ambient]`. The implementation
/// uses top-level `[ambient]` with nested `[ambient.discord]` to allow future
/// multi-platform ambient support without restructuring config.
#[derive(Debug, Clone, Deserialize)]
pub struct AmbientConfig {
/// Master switch (default: false).
#[serde(default)]
pub enabled: bool,
/// Time-based flush trigger in seconds (±20% jitter applied). Default: 60.
#[serde(default = "default_flush_interval_seconds")]
pub flush_interval_seconds: u64,
/// Count-based flush trigger. Default: 10.
#[serde(default = "default_flush_max_messages")]
pub flush_max_messages: usize,
/// Safety cap — force flush at this count even if timer hasn't expired.
/// Only relevant when `flush_max_messages` is set very high or disabled. Default: 50.
#[serde(default = "default_flush_hard_cap")]
pub flush_hard_cap: usize,
/// Historical messages fetched via Discord API before the batch. Default: 20.
/// NOTE: Not yet implemented (v2 follow-up). Parsed but not used at runtime.
#[serde(default = "default_context_window")]
pub context_window: usize,
/// Max simultaneous LLM calls across all ambient channels. Default: 3.
#[serde(default = "default_max_concurrent_flushes")]
pub max_concurrent_flushes: usize,
/// Safety timeout (seconds) — auto-reset flushing flag if exceeded. Default: 120.
#[serde(default = "default_flush_timeout_seconds")]
pub flush_timeout_seconds: u64,
/// Ambient session pool configuration.
#[serde(default)]
pub pool: AmbientPoolConfig,
/// Platform-specific ambient settings.
#[serde(default)]
pub discord: AmbientDiscordConfig,
}

impl Default for AmbientConfig {
fn default() -> Self {
Self {
enabled: false,
flush_interval_seconds: default_flush_interval_seconds(),
flush_max_messages: default_flush_max_messages(),
flush_hard_cap: default_flush_hard_cap(),
context_window: default_context_window(),
max_concurrent_flushes: default_max_concurrent_flushes(),
flush_timeout_seconds: default_flush_timeout_seconds(),
pool: AmbientPoolConfig::default(),
discord: AmbientDiscordConfig::default(),
}
}
}

/// `[ambient.pool]` — dedicated session pool for ambient dispatches.
///
/// NOTE: Pool management is not yet implemented (v2 follow-up). These settings
/// are parsed and validated on startup but not enforced at runtime.
#[derive(Debug, Clone, Deserialize)]
pub struct AmbientPoolConfig {
/// Max concurrent ambient sessions. Default: 5.
#[serde(default = "default_ambient_max_sessions")]
pub max_sessions: usize,
/// Ambient session inactivity timeout in minutes. Default: 60.
#[serde(default = "default_ambient_session_ttl_minutes")]
pub session_ttl_minutes: u64,
/// Rolling window of retained flush history (cross-flush memory). Default: 3.
#[serde(default = "default_ambient_context_flushes")]
pub context_flushes: usize,
}

impl Default for AmbientPoolConfig {
fn default() -> Self {
Self {
max_sessions: default_ambient_max_sessions(),
session_ttl_minutes: default_ambient_session_ttl_minutes(),
context_flushes: default_ambient_context_flushes(),
}
}
}

/// `[ambient.discord]` — Discord-specific ambient settings.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AmbientDiscordConfig {
/// Explicit channel allowlist. Required — empty means ambient is disabled.
#[serde(default)]
pub channels: Vec<String>,
/// Whether other bots' messages enter the ambient buffer. Default: false.
#[serde(default)]
pub allow_bot_messages: bool,
}

fn default_flush_interval_seconds() -> u64 {
60
}
fn default_flush_max_messages() -> usize {
10
}
fn default_flush_hard_cap() -> usize {
50
}
fn default_context_window() -> usize {
20
}
fn default_max_concurrent_flushes() -> usize {
3
}
fn default_flush_timeout_seconds() -> u64 {
120
}
fn default_ambient_max_sessions() -> usize {
5
}
fn default_ambient_session_ttl_minutes() -> u64 {
60
}
fn default_ambient_context_flushes() -> usize {
3
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
60 changes: 60 additions & 0 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::acp::ContentBlock;
use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext};
use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX};
use crate::config::{AllowBots, AllowUsers, SttConfig};
use crate::dispatch::DispatchTarget;
use crate::format;
use crate::media;
use crate::remind::{self, ReminderStore};
Expand Down Expand Up @@ -234,6 +235,8 @@ pub struct Handler {
pub allow_dm: bool,
/// Per-thread dispatcher (Message mode uses cap=1 for FIFO; Thread/Lane use configured cap).
pub dispatcher: Arc<crate::dispatch::Dispatcher>,
/// Ambient mode dispatcher for passive channel listening.
pub ambient: Option<Arc<crate::ambient::AmbientDispatcher>>,
/// Reminder store for /remind slash command.
pub reminder_store: ReminderStore,
/// Track scheduled reminder IDs to prevent duplicate scheduling on reconnect.
Expand Down Expand Up @@ -604,6 +607,63 @@ impl EventHandler for Handler {
return;
}

// --- Ambient Mode routing ---
// If the message is in an ambient-enabled channel, NOT a @mention,
// NOT in a thread, and NOT a DM → route to ambient dispatcher.
// @mention in an ambient channel → discard buffer + normal dispatch.
if let Some(ref ambient) = self.ambient {
if ambient.is_ambient_channel(channel_id) && !in_thread && !is_dm {
if is_mentioned {
// Discard ambient buffer — mention takes priority.
ambient.discard_buffer(&channel_id.to_string()).await;
// Fall through to normal dispatch below.
} else {
// Route to ambient buffer (not normal dispatch).
// Bot messages only if allow_bot_messages is true for ambient.
if msg.author.bot && !ambient.allow_bot_messages() {
return;
}

let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids);
if prompt.is_empty() && msg.attachments.is_empty() {
return;
}

let display_name = msg
.member
.as_ref()
.and_then(|m| m.nick.as_ref())
.or(msg.author.global_name.as_ref())
.unwrap_or(&msg.author.name);

let channel_ref = ChannelRef {
platform: "discord".into(),
channel_id: channel_id.to_string(),
thread_id: None,
parent_id: None,
origin_event_id: None,
};

let ambient_msg = crate::ambient::AmbientMessage {
sender_name: display_name.to_owned(),
prompt,
extra_blocks: Vec::new(), // Skip attachments for ambient v1
arrived_at: std::time::Instant::now(),
};

let target = Arc::clone(&self.router) as Arc<dyn DispatchTarget>;
ambient.submit(
&channel_id.to_string(),
channel_ref,
adapter.clone(),
target,
ambient_msg,
).await;
return;
}
}
}

// User message gating (mirrors Slack's AllowUsers logic).
// Mentions: always require @mention, even in bot's own threads.
// Involved (default): skip @mention if the bot owns the thread
Expand Down
2 changes: 2 additions & 0 deletions crates/openab-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ pub mod timestamp;

#[cfg(feature = "discord")]
pub mod discord;
#[cfg(feature = "discord")]
pub mod ambient;
#[cfg(feature = "slack")]
pub mod slack;
100 changes: 100 additions & 0 deletions docs/ambient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Ambient Mode

Ambient mode allows your bot to passively listen to all messages in configured channels and autonomously decide whether to respond. Unlike the default @mention mode, the bot observes the full conversation flow and only speaks up when it has something valuable to add.

## How It Works

1. Messages in configured channels (that are **not** @mentions) are buffered per-channel.
2. When a **time trigger** (`flush_interval_seconds`) or **count trigger** (`flush_max_messages`) fires, the batch is sent to the LLM.
3. The LLM evaluates the conversation and either:
- **Replies** with a helpful response → posted to the channel.
- **Returns `[NO_REPLY]`** → silently suppressed, nothing is posted.
4. If someone **@mentions** the bot in an ambient channel, the buffer is discarded and the mention is handled normally (immediate response).

## Configuration

```toml
[ambient]
enabled = true
flush_interval_seconds = 60 # Time trigger (±20% jitter applied)
flush_max_messages = 10 # Count trigger
flush_hard_cap = 50 # Safety cap on buffer size
max_concurrent_flushes = 3 # Global LLM concurrency limit
flush_timeout_seconds = 120 # Safety timeout per flush

[ambient.discord]
channels = ["1234567890"] # Channel IDs to monitor
allow_bot_messages = false # Include other bots' messages in buffer
```

### Configuration fields

| Field | Default | Description |
|-------|---------|-------------|
| `enabled` | `false` | Master switch. Must be explicitly enabled. |
| `flush_interval_seconds` | `60` | Seconds between time-based flushes. ±20% jitter prevents thundering herd. Min: 1. |
| `flush_max_messages` | `10` | Flush when this many messages accumulate. Min: 1. |
| `flush_hard_cap` | `50` | Maximum buffer size. Messages beyond this are dropped. Min: 1. |
| `max_concurrent_flushes` | `3` | Max simultaneous LLM calls across all ambient channels. Min: 1. |
| `flush_timeout_seconds` | `120` | Safety timeout — resets flushing state if exceeded. Clamped to [5, 600]. |
| `channels` | `[]` | Explicit channel allowlist (required). Empty = ambient disabled. |
| `allow_bot_messages` | `false` | Whether other bots' messages enter the ambient buffer. |

### Reserved fields (v2, not yet enforced)

| Field | Default | Description |
|-------|---------|-------------|
| `context_window` | `20` | Historical messages to fetch before each batch (not yet implemented). |
| `pool.max_sessions` | `5` | Max concurrent ambient sessions (not yet enforced). |
| `pool.session_ttl_minutes` | `60` | Session inactivity timeout (not yet enforced). |
| `pool.context_flushes` | `3` | Rolling flush history window (not yet enforced). |

## Behavior

### @mention priority

When someone @mentions the bot in an ambient channel:
1. The ambient buffer is immediately invalidated (current batch discarded).
2. The mention is handled via normal dispatch (immediate response).
3. After the mention is handled, ambient buffering resumes for new messages.

Buffered messages that arrived before the mention are **not lost** — they carry into the next ambient cycle.

### [NO_REPLY] filtering

The bot uses a system prompt that instructs it to respond with `[NO_REPLY]` when it has nothing to add. This sentinel is intercepted **before delivery** — it never appears in the channel. The filtering uses a capture adapter that forces non-streaming mode to ensure the full response is evaluated before any message is sent.

### Session isolation

Ambient sessions use the namespace `ambient:discord:<channel_id>`, separate from normal dispatch sessions. There is no collision with @mention sessions.

### Cost control

- **Jittered intervals** prevent all channels from flushing simultaneously.
- **Global semaphore** caps concurrent LLM calls (default: 3).
- **`[NO_REPLY]`** means most flushes produce no visible output (only one LLM call, no channel message).
- **`enabled = false`** default means zero cost until explicitly opted in.

## Limitations (v1)

| Limitation | Description | Planned fix |
|-----------|-------------|-------------|
| Tool access | Ambient flushes have full tool access (same as @mention). | v2: restricted dispatch target |
| In-flight cancel | A @mention during LLM generation cannot stop the ambient response mid-stream. | v2: `tokio::select!` preemption |
| Consumer supervision | If a consumer task panics, that channel's ambient is permanently disabled until restart. | v2: health check + respawn |
| No history fetch | `context_window` (Discord API history before batch) is not yet implemented. | v2 |
| No cooldown | No minimum interval between consecutive flushes for a single channel. | v2 |

## Example

Minimal config to enable ambient mode on one channel:

```toml
[ambient]
enabled = true

[ambient.discord]
channels = ["1490282656913559673"]
```

This uses all defaults: 60s flush interval, max 10 messages per batch, 3 concurrent flushes.
26 changes: 26 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,32 @@ web = "~/projects/frontend"

---

## `[ambient]`

Passive channel listening with batch flush. See [ambient.md](ambient.md) for full guide.

```toml
[ambient]
enabled = false # Master switch
flush_interval_seconds = 60 # Time trigger (±20% jitter)
flush_max_messages = 10 # Count trigger
flush_hard_cap = 50 # Max buffer size
max_concurrent_flushes = 3 # Global LLM concurrency limit
flush_timeout_seconds = 120 # Safety timeout per flush
context_window = 20 # (v2, not yet implemented)

[ambient.pool] # (v2, not yet enforced)
max_sessions = 5
session_ttl_minutes = 60
context_flushes = 3

[ambient.discord]
channels = [] # Channel ID allowlist (required)
allow_bot_messages = false
```

---

## `[cron]`

Everything cron-related lives under `[cron]`.
Expand Down
19 changes: 19 additions & 0 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,25 @@ Each thread gets its own agent session. Sessions are cleaned up after `session_t

---

## Ambient Mode

Ambient mode allows the bot to passively listen to configured channels and respond only when it has something valuable to add — without requiring @mentions. See [ambient.md](ambient.md) for full details.

```toml
[ambient]
enabled = true

[ambient.discord]
channels = ["1234567890"] # Channel IDs to monitor
```

When enabled:
- Non-mention messages in listed channels are buffered and periodically sent to the LLM as a batch.
- If the LLM has nothing to add, it returns `[NO_REPLY]` (silently suppressed).
- **@mention always takes priority** — the ambient buffer is discarded and the mention gets an immediate response.

---

## Attachment Handling

OpenAB processes Discord file attachments and converts them into content blocks
Expand Down
Loading
Loading