diff --git a/config.toml.example b/config.toml.example index 83cd74a37..ecc0ec3a7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -260,3 +260,18 @@ error_hold_ms = 2500 # on_failure = "abort" # "abort" or "warn" (default: "abort") # region = "us-west-2" # optional: override AWS region # endpoint_url = "http://localhost:4566" # optional: LocalStack / VPC endpoint + +# --- Ambient Mode --- +# Passive channel observation: bot listens without @mention and decides when to reply. +# [ambient] +# enabled = true +# flush_interval_seconds = 60 # ±20% jitter applied +# flush_max_messages = 10 # flush when N messages buffered +# flush_hard_cap = 50 # safety cap on buffer +# max_concurrent_flushes = 3 # global LLM concurrency +# flush_timeout_seconds = 120 # safety timeout per flush +# instructions_file = "~/.openab/config/ambient.md" # custom system prompt (first 2000 chars) +# +# [ambient.discord] +# channels = ["1234567890"] # channel IDs to observe +# allow_bot_messages = false # include other bots in buffer diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index 8d7506509..c21b0d804 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -47,8 +47,11 @@ use async_trait::async_trait; /// Sentinel value the agent returns when it has nothing to add. const NO_REPLY_SENTINEL: &str = "[no_reply]"; -/// System instruction prepended to every ambient batch. -const AMBIENT_SYSTEM_INSTRUCTION: &str = r#"You are in ambient mode. Below is a batch of recent messages from the channel. You are passively observing the conversation. +/// Maximum characters to read from the instructions file. +const INSTRUCTIONS_FILE_MAX_CHARS: usize = 2000; + +/// Default system instruction used when no instructions file is found. +const DEFAULT_AMBIENT_SYSTEM_INSTRUCTION: &str = r#"You are in ambient mode. Below is a batch of recent messages from the channel. You are passively observing the conversation. Rules: - If you have nothing valuable to add, reply EXACTLY: [NO_REPLY] @@ -58,6 +61,53 @@ Rules: - Do not acknowledge that you are in ambient mode "#; +/// Load ambient system instruction from the configured file path. +/// Falls back to `DEFAULT_AMBIENT_SYSTEM_INSTRUCTION` if the file does not exist. +/// Truncates to `INSTRUCTIONS_FILE_MAX_CHARS` characters. +fn load_instructions(path: &str) -> String { + let expanded = if let Some(stripped) = path.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + std::path::PathBuf::from(home).join(stripped) + } else { + std::path::PathBuf::from(path) + } + } else { + std::path::PathBuf::from(path) + }; + + // Warn if path is outside $HOME + if let Some(home) = std::env::var_os("HOME") { + if !expanded.starts_with(std::path::Path::new(&home)) { + warn!(path = %expanded.display(), "ambient: instructions_file is outside $HOME"); + } + } + + match std::fs::read_to_string(&expanded) { + Ok(content) => { + let char_count = content.chars().count(); + let truncated: String = content.chars().take(INSTRUCTIONS_FILE_MAX_CHARS).collect(); + if char_count > INSTRUCTIONS_FILE_MAX_CHARS { + warn!( + path = %expanded.display(), + original_chars = char_count, + max = INSTRUCTIONS_FILE_MAX_CHARS, + "ambient: instructions file truncated" + ); + } + info!(path = %expanded.display(), chars = truncated.len(), "ambient: loaded custom instructions"); + truncated + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + debug!(path = %expanded.display(), "ambient: instructions file not found, using default"); + DEFAULT_AMBIENT_SYSTEM_INSTRUCTION.to_string() + } + Err(e) => { + warn!(path = %expanded.display(), error = %e, "ambient: failed to read instructions file, using default"); + DEFAULT_AMBIENT_SYSTEM_INSTRUCTION.to_string() + } + } +} + // --------------------------------------------------------------------------- // AmbientMessage — lighter than BufferedMessage for ambient buffering // --------------------------------------------------------------------------- @@ -258,6 +308,8 @@ pub struct AmbientDispatcher { enabled_channels: HashSet, /// Global semaphore limiting concurrent flush operations. flush_semaphore: Arc, + /// Loaded system instruction (from file or default). + instructions: String, } impl AmbientDispatcher { @@ -273,6 +325,7 @@ impl AmbientDispatcher { .filter_map(|s| s.parse().ok()) .collect(); let flush_semaphore = Arc::new(Semaphore::new(config.max_concurrent_flushes.max(1))); + let instructions = load_instructions(&config.instructions_file); if config.enabled && !enabled_channels.is_empty() { tracing::info!( channels = ?enabled_channels, @@ -284,6 +337,7 @@ impl AmbientDispatcher { channels: Mutex::new(HashMap::new()), enabled_channels, flush_semaphore, + instructions, } } @@ -357,6 +411,7 @@ impl AmbientDispatcher { Arc::clone(&post_guard), adapter, target, + self.instructions.clone(), )); channels.insert( @@ -422,6 +477,7 @@ async fn ambient_consumer_loop( post_guard: Arc, adapter: Arc, target: Arc, + instructions: String, ) { info!(channel_id = %channel_id, "ambient consumer started"); @@ -505,7 +561,7 @@ async fn ambient_consumer_loop( // Build the batch payload. let session_key = format!("ambient:{}:{}", channel_ref.platform, channel_id); - let content_blocks = build_ambient_payload(&batch); + let content_blocks = build_ambient_payload(&batch, &instructions); // Ensure session exists. if let Err(e) = target.ensure_session(&session_key, None).await { @@ -580,12 +636,12 @@ async fn ambient_consumer_loop( // --------------------------------------------------------------------------- /// Build the content blocks for an ambient batch dispatch. -fn build_ambient_payload(batch: &[AmbientMessage]) -> Vec { +fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec { let mut blocks = Vec::new(); // System instruction. blocks.push(ContentBlock::Text { - text: AMBIENT_SYSTEM_INSTRUCTION.to_string(), + text: instructions.to_string(), }); // Format batch as conversation transcript. @@ -706,4 +762,37 @@ mod tests { guard.reset(); assert!(guard.can_post()); } + + #[test] + fn load_instructions_file_exists() { + let dir = std::env::temp_dir().join("oab_test_ambient"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test_instructions.md"); + std::fs::write(&path, "custom prompt").unwrap(); + + let result = super::load_instructions(path.to_str().unwrap()); + assert_eq!(result, "custom prompt"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn load_instructions_file_missing_fallback() { + let result = super::load_instructions("/tmp/nonexistent_oab_test_file_xyz.md"); + assert_eq!(result, super::DEFAULT_AMBIENT_SYSTEM_INSTRUCTION); + } + + #[test] + fn load_instructions_truncates_at_limit() { + let dir = std::env::temp_dir().join("oab_test_ambient_trunc"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long_instructions.md"); + let long_content = "x".repeat(3000); + std::fs::write(&path, &long_content).unwrap(); + + let result = super::load_instructions(path.to_str().unwrap()); + assert_eq!(result.len(), super::INSTRUCTIONS_FILE_MAX_CHARS); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 43a85dae9..6575c21e5 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1259,6 +1259,11 @@ pub struct AmbientConfig { /// Safety timeout (seconds) — auto-reset flushing flag if exceeded. Default: 120. #[serde(default = "default_flush_timeout_seconds")] pub flush_timeout_seconds: u64, + /// Path to a custom instructions file for the ambient system prompt. + /// Default: `~/.openab/config/ambient.md`. If the file exists, its content + /// (up to 2000 characters) replaces the built-in system instruction. + #[serde(default = "default_instructions_file")] + pub instructions_file: String, /// Ambient session pool configuration. #[serde(default)] pub pool: AmbientPoolConfig, @@ -1277,6 +1282,7 @@ impl Default for AmbientConfig { context_window: default_context_window(), max_concurrent_flushes: default_max_concurrent_flushes(), flush_timeout_seconds: default_flush_timeout_seconds(), + instructions_file: default_instructions_file(), pool: AmbientPoolConfig::default(), discord: AmbientDiscordConfig::default(), } @@ -1339,6 +1345,9 @@ fn default_max_concurrent_flushes() -> usize { fn default_flush_timeout_seconds() -> u64 { 120 } +fn default_instructions_file() -> String { + "~/.openab/config/ambient.md".to_string() +} fn default_ambient_max_sessions() -> usize { 5 } diff --git a/docs/ambient.md b/docs/ambient.md index f78faa86e..0129f757d 100644 --- a/docs/ambient.md +++ b/docs/ambient.md @@ -21,12 +21,32 @@ 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 +instructions_file = "~/.openab/config/ambient.md" # Custom system prompt (optional) [ambient.discord] channels = ["1234567890"] # Channel IDs to monitor (and their threads) allow_bot_messages = false # Include other bots' messages in buffer ``` +### Custom Instructions + +The `instructions_file` field points to a Markdown file whose content is used as the ambient system prompt. This works like a user-defined `.cursorrules` or `CLAUDE.md` — you control how the bot behaves in ambient mode by editing one file. + +- **Default path:** `~/.openab/config/ambient.md` +- **Max length:** First 2000 characters are used; content beyond that is truncated. +- **Fallback:** If the file does not exist, the built-in default instructions are used. +- **Restart required:** The file is read once at startup. To apply changes, restart the bot. + +Example `~/.openab/config/ambient.md`: + +```markdown +You are passively observing a Discord channel. + +- Reply EXACTLY `[NO_REPLY]` if you have nothing to add +- Only speak up for technical corrections or when directly asked +- Keep replies concise +``` + ### Configuration fields | Field | Default | Description | @@ -37,6 +57,7 @@ allow_bot_messages = false # Include other bots' messages in buffer | `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]. | +| `instructions_file` | `~/.openab/config/ambient.md` | Path to custom instructions file. First 2000 chars used as system prompt. Falls back to built-in default if missing. | | `channels` | `[]` | Explicit channel allowlist (required). Empty = ambient disabled. | | `allow_bot_messages` | `false` | Whether other bots' messages enter the ambient buffer. |