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
100 changes: 91 additions & 9 deletions crates/openab-core/src/ambient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ const NO_REPLY_SENTINEL: &str = "[no_reply]";
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.
const DEFAULT_AMBIENT_SYSTEM_INSTRUCTION: &str = r#"You are in ambient mode. Below is a batch of recent messages from the channel.

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
- Reply when someone asks a question, needs help, or when you can add useful context
- If the conversation is purely social chatter with no question or actionable topic, reply EXACTLY: [NO_REPLY]
- When replying, always @mention the person you are responding to (e.g. <@123456789>) so they get notified. If multiple people are relevant, mention all of them.
- Keep replies concise and natural — you are joining an ongoing conversation, not starting one
- Do not acknowledge that you are in ambient mode
"#;
Expand Down Expand Up @@ -117,6 +117,8 @@ fn load_instructions(path: &str) -> String {
pub struct AmbientMessage {
/// Author display name.
pub sender_name: String,
/// Discord UID of the sender (for @mentions in replies).
pub sender_id: String,
/// User-visible prompt text.
pub prompt: String,
/// Attachment blocks.
Expand Down Expand Up @@ -209,6 +211,7 @@ impl PostGuard {
/// `[NO_REPLY]` responses before they reach the channel.
struct AmbientCaptureAdapter {
inner: Arc<dyn ChatAdapter>,
debug: bool,
}

#[async_trait]
Expand All @@ -226,8 +229,8 @@ impl ChatAdapter for AmbientCaptureAdapter {
}

async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result<MessageRef> {
// Filter [NO_REPLY] before it reaches the channel.
if is_no_reply(content) {
// Filter [NO_REPLY] before it reaches the channel (unless debug mode).
if is_no_reply(content) && !self.debug {
debug!("ambient: suppressed [NO_REPLY] response");
return Ok(MessageRef {
channel: channel.clone(),
Expand Down Expand Up @@ -264,7 +267,7 @@ impl ChatAdapter for AmbientCaptureAdapter {
content: &str,
reply_to_message_id: &str,
) -> Result<MessageRef> {
if is_no_reply(content) {
if is_no_reply(content) && !self.debug {
debug!("ambient: suppressed [NO_REPLY] reply");
return Ok(MessageRef {
channel: channel.clone(),
Expand Down Expand Up @@ -563,6 +566,34 @@ async fn ambient_consumer_loop(
let session_key = format!("ambient:{}:{}", channel_ref.platform, channel_id);
let content_blocks = build_ambient_payload(&batch, &instructions);

// Debug mode: notify channel that a flush is happening.
if config.debug {
let senders: Vec<&str> = batch.iter().map(|m| m.sender_name.as_str()).collect();
let transcript = batch.iter()
.map(|m| format!("{} (<@{}>): {}", m.sender_name, m.sender_id, m.prompt.replace('`', "'")))
.collect::<Vec<_>>()
.join("\n");
let mut debug_msg = format!(
"🔍 **ambient debug** — flushing {} message(s) from: {}\n**System prompt:**\n```\n{}\n```\n**Transcript:**\n```\n{}\n```",
batch_size,
senders.join(", "),
instructions.replace('`', "'"),
transcript
);
// Truncate to stay within Discord's 2000-char message limit (UTF-8 safe).
if debug_msg.len() > 1900 {
let mut end = 1900;
while !debug_msg.is_char_boundary(end) {
end -= 1;
}
debug_msg.truncate(end);
debug_msg.push_str("\n…(truncated)");
}
if let Err(e) = adapter.send_message(&channel_ref, &debug_msg).await {
warn!(channel_id = %channel_id, error = %e, "ambient debug message send failed");
}
}

// Ensure session exists.
if let Err(e) = target.ensure_session(&session_key, None).await {
warn!(
Expand All @@ -583,6 +614,7 @@ async fn ambient_consumer_loop(
// v2 should use a restricted target or disable tools for ambient sessions.
let capture_adapter: Arc<dyn ChatAdapter> = Arc::new(AmbientCaptureAdapter {
inner: Arc::clone(&adapter),
debug: config.debug,
});
let dummy_msg_ref = MessageRef {
channel: channel_ref.clone(),
Expand Down Expand Up @@ -639,7 +671,7 @@ async fn ambient_consumer_loop(
fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec<ContentBlock> {
let mut blocks = Vec::new();

// System instruction.
// System instruction from file.
blocks.push(ContentBlock::Text {
text: instructions.to_string(),
});
Expand All @@ -650,8 +682,12 @@ fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec<Co
transcript.push_str(" new messages]\n");

for msg in batch {
// NOTE: Discord-specific mention format (<@UID>). When adding other
// platforms, pass a formatter or adapt per-platform. (v2)
transcript.push_str(&msg.sender_name);
transcript.push_str(": ");
transcript.push_str(" (<@");
transcript.push_str(&msg.sender_id);
transcript.push_str(">): ");
transcript.push_str(&msg.prompt);
transcript.push('\n');
}
Expand All @@ -677,6 +713,52 @@ pub fn is_no_reply(response: &str) -> bool {
mod tests {
use super::*;

fn first_text(blocks: &[ContentBlock]) -> &str {
match &blocks[0] {
ContentBlock::Text { text } => text.as_str(),
#[allow(unreachable_patterns)]
_ => panic!("first block is not text"),
}
}

fn msg(s: &str) -> AmbientMessage {
AmbientMessage {
sender_name: "Pahud".into(),
sender_id: "845835116920307722".into(),
prompt: s.into(),
extra_blocks: Vec::new(),
arrived_at: std::time::Instant::now(),
}
}

#[test]
fn payload_without_instructions_is_default_prompt() {
let blocks = build_ambient_payload(&[msg("hi")], super::DEFAULT_AMBIENT_SYSTEM_INSTRUCTION);
let sys = first_text(&blocks);
assert!(sys.contains("You are in ambient mode"));
assert!(sys.contains("[NO_REPLY]"));
assert!(sys.contains("@mention the person"));
}

#[test]
fn payload_uses_custom_instructions() {
let blocks = build_ambient_payload(&[msg("hi")], "Custom prompt with [NO_REPLY] rule.");
let sys = first_text(&blocks);
assert!(sys.contains("Custom prompt"));
assert!(sys.contains("[NO_REPLY]"));
}

#[test]
fn payload_transcript_includes_sender_uid() {
let blocks = build_ambient_payload(&[msg("hello")], super::DEFAULT_AMBIENT_SYSTEM_INSTRUCTION);
let transcript = match &blocks[1] {
ContentBlock::Text { text } => text.as_str(),
#[allow(unreachable_patterns)]
_ => panic!("second block is not text"),
};
assert!(transcript.contains("Pahud (<@845835116920307722>): hello"));
}

fn dispatcher(channels: &[&str], enabled: bool) -> AmbientDispatcher {
let config = AmbientConfig {
enabled,
Expand Down
7 changes: 7 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,12 @@ pub struct AmbientConfig {
/// Platform-specific ambient settings.
#[serde(default)]
pub discord: AmbientDiscordConfig,
/// Debug mode: when true, [NO_REPLY] responses are sent to the channel
/// instead of being suppressed, allowing observation of ambient behavior.
/// ⚠️ WARNING: This exposes the system prompt and buffered messages to the
/// channel. Only use in private/test channels, never in production.
#[serde(default)]
pub debug: bool,
}

impl Default for AmbientConfig {
Expand All @@ -1285,6 +1291,7 @@ impl Default for AmbientConfig {
instructions_file: default_instructions_file(),
pool: AmbientPoolConfig::default(),
discord: AmbientDiscordConfig::default(),
debug: false,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ impl EventHandler for Handler {

let ambient_msg = crate::ambient::AmbientMessage {
sender_name: display_name.to_owned(),
sender_id: msg.author.id.to_string(),
prompt,
extra_blocks: Vec::new(), // Skip attachments for ambient v1
arrived_at: std::time::Instant::now(),
Expand Down
2 changes: 2 additions & 0 deletions docs/ambient.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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)
debug = false # ⚠️ Only enable in test channels (exposes prompt + messages)

[ambient.discord]
channels = ["1234567890"] # Channel IDs to monitor (and their threads)
Expand Down Expand Up @@ -52,6 +53,7 @@ You are passively observing a Discord channel.
| Field | Default | Description |
|-------|---------|-------------|
| `enabled` | `false` | Master switch. Must be explicitly enabled. |
| `debug` | `false` | When true, posts system prompt + transcript to channel on each flush and lets `[NO_REPLY]` responses through (not suppressed). ⚠️ **Only use in private/test channels** — exposes system prompt and buffered messages. |
| `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. |
Expand Down
Loading