From de9c5e31ed869643b654efaa0e8ae9fe20a62b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sat, 27 Jun 2026 16:04:39 +0000 Subject: [PATCH 1/4] feat(ambient): add instructions_file for custom system prompt Load ambient system instructions from ~/.openab/config/ambient.md (configurable via instructions_file field). First 2000 characters are used; falls back to built-in default if the file is missing. This allows users to customize ambient behavior by editing a single file without touching config.toml. --- config.toml.example | 15 +++++++++ crates/openab-core/src/ambient.rs | 54 +++++++++++++++++++++++++------ crates/openab-core/src/config.rs | 9 ++++++ docs/ambient.md | 21 ++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) 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..0f630e525 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -47,17 +47,47 @@ 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] -- Only reply when you can provide meaningful help, context, or corrections -- Do not reply to other bot messages unless directly relevant to a human's question -- Keep replies concise and natural — you are joining an ongoing conversation, not starting one +- If you truly have nothing to add, reply EXACTLY: [NO_REPLY] +- Feel free to jump in when you can help, share relevant knowledge, offer suggestions, or add to the discussion +- You can respond to interesting topics, answer questions (even if not directed at you), or provide useful context +- Keep replies concise and natural — you are part of the conversation - 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 path.starts_with("~/") { + if let Some(home) = std::env::var_os("HOME") { + std::path::PathBuf::from(home).join(&path[2..]) + } else { + std::path::PathBuf::from(path) + } + } else { + std::path::PathBuf::from(path) + }; + + match std::fs::read_to_string(&expanded) { + Ok(content) => { + let truncated: String = content.chars().take(INSTRUCTIONS_FILE_MAX_CHARS).collect(); + info!(path = %expanded.display(), chars = truncated.len(), "ambient: loaded custom instructions"); + truncated + } + Err(_) => { + debug!(path = %expanded.display(), "ambient: instructions file not found, using default"); + DEFAULT_AMBIENT_SYSTEM_INSTRUCTION.to_string() + } + } +} + // --------------------------------------------------------------------------- // AmbientMessage — lighter than BufferedMessage for ambient buffering // --------------------------------------------------------------------------- @@ -258,6 +288,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 +305,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 +317,7 @@ impl AmbientDispatcher { channels: Mutex::new(HashMap::new()), enabled_channels, flush_semaphore, + instructions, } } @@ -357,6 +391,7 @@ impl AmbientDispatcher { Arc::clone(&post_guard), adapter, target, + self.instructions.clone(), )); channels.insert( @@ -422,6 +457,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 +541,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 +616,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. 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..10561e293 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. +- **No 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. | From fe680f5b80ef709597145996ff54029bd85b4fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sat, 27 Jun 2026 16:35:29 +0000 Subject: [PATCH 2/4] fix(ambient): add warn logs and unit tests for load_instructions - Warn when instructions_file path is outside $HOME - Warn when file content is truncated at 2000 chars - Add unit tests: file exists, file missing fallback, truncation --- crates/openab-core/src/ambient.rs | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index 0f630e525..eec058ca8 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -75,9 +75,25 @@ fn load_instructions(path: &str) -> String { 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 } @@ -742,4 +758,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(); + } } From 38e5864dc6fc641a7342190eb7e89cd7fe575b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sat, 27 Jun 2026 16:39:22 +0000 Subject: [PATCH 3/4] fix(review): address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: Differentiate NotFound from other IO errors in load_instructions() - F2: Fix contradictory doc (No restart → Restart required) - F3: Revert default prompt to original conservative wording --- crates/openab-core/src/ambient.rs | 14 +++++++++----- docs/ambient.md | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index eec058ca8..222d65c8d 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -54,10 +54,10 @@ const INSTRUCTIONS_FILE_MAX_CHARS: usize = 2000; 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 truly have nothing to add, reply EXACTLY: [NO_REPLY] -- Feel free to jump in when you can help, share relevant knowledge, offer suggestions, or add to the discussion -- You can respond to interesting topics, answer questions (even if not directed at you), or provide useful context -- Keep replies concise and natural — you are part of the conversation +- If you have nothing valuable to add, reply EXACTLY: [NO_REPLY] +- Only reply when you can provide meaningful help, context, or corrections +- Do not reply to other bot messages unless directly relevant to a human's question +- Keep replies concise and natural — you are joining an ongoing conversation, not starting one - Do not acknowledge that you are in ambient mode "#; @@ -97,10 +97,14 @@ fn load_instructions(path: &str) -> String { info!(path = %expanded.display(), chars = truncated.len(), "ambient: loaded custom instructions"); truncated } - Err(_) => { + 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() + } } } diff --git a/docs/ambient.md b/docs/ambient.md index 10561e293..0129f757d 100644 --- a/docs/ambient.md +++ b/docs/ambient.md @@ -35,7 +35,7 @@ The `instructions_file` field points to a Markdown file whose content is used as - **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. -- **No restart required:** The file is read once at startup. To apply changes, restart the bot. +- **Restart required:** The file is read once at startup. To apply changes, restart the bot. Example `~/.openab/config/ambient.md`: From 8c9869b969a78aa6628b017a074b97df1ca1b958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sat, 27 Jun 2026 18:09:22 +0000 Subject: [PATCH 4/4] fix(ambient): use strip_prefix to satisfy clippy::manual_strip --- crates/openab-core/src/ambient.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index 222d65c8d..c21b0d804 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -65,9 +65,9 @@ Rules: /// 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 path.starts_with("~/") { + let expanded = if let Some(stripped) = path.strip_prefix("~/") { if let Some(home) = std::env::var_os("HOME") { - std::path::PathBuf::from(home).join(&path[2..]) + std::path::PathBuf::from(home).join(stripped) } else { std::path::PathBuf::from(path) }