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
15 changes: 15 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
99 changes: 94 additions & 5 deletions crates/openab-core/src/ambient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -258,6 +308,8 @@ pub struct AmbientDispatcher {
enabled_channels: HashSet<u64>,
/// Global semaphore limiting concurrent flush operations.
flush_semaphore: Arc<Semaphore>,
/// Loaded system instruction (from file or default).
instructions: String,
}

impl AmbientDispatcher {
Expand All @@ -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,
Expand All @@ -284,6 +337,7 @@ impl AmbientDispatcher {
channels: Mutex::new(HashMap::new()),
enabled_channels,
flush_semaphore,
instructions,
}
}

Expand Down Expand Up @@ -357,6 +411,7 @@ impl AmbientDispatcher {
Arc::clone(&post_guard),
adapter,
target,
self.instructions.clone(),
));

channels.insert(
Expand Down Expand Up @@ -422,6 +477,7 @@ async fn ambient_consumer_loop(
post_guard: Arc<PostGuard>,
adapter: Arc<dyn ChatAdapter>,
target: Arc<dyn DispatchTarget>,
instructions: String,
) {
info!(channel_id = %channel_id, "ambient consumer started");

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ContentBlock> {
fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec<ContentBlock> {
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.
Expand Down Expand Up @@ -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();
}
}
9 changes: 9 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
}
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions docs/ambient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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. |

Expand Down
Loading