From 2edeef914c321dd84e7044b9100b7a51c05827a9 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 27 Jun 2026 11:18:04 -0400 Subject: [PATCH] feat(ambient): relax default prompt, sender UID in transcript, debug mode - Relax DEFAULT_AMBIENT_SYSTEM_INSTRUCTION: bias toward engagement, only [NO_REPLY] for purely social chatter - Add @mention rule: bot always mentions the person it's responding to - Add sender_id to AmbientMessage, format transcript as: Name (<@UID>): message - Add debug config: when [ambient] debug = true: - [NO_REPLY] responses are sent to the channel (not suppressed) - A system message is posted on each flush showing batch size and senders - Add tests for new prompt and UID-in-transcript behavior --- crates/openab-core/src/ambient.rs | 100 +++++++++++++++++++++++++++--- crates/openab-core/src/config.rs | 7 +++ crates/openab-core/src/discord.rs | 1 + docs/ambient.md | 2 + 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index c21b0d804..f0ef5d2ae 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -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 "#; @@ -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. @@ -209,6 +211,7 @@ impl PostGuard { /// `[NO_REPLY]` responses before they reach the channel. struct AmbientCaptureAdapter { inner: Arc, + debug: bool, } #[async_trait] @@ -226,8 +229,8 @@ impl ChatAdapter for AmbientCaptureAdapter { } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { - // 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(), @@ -264,7 +267,7 @@ impl ChatAdapter for AmbientCaptureAdapter { content: &str, reply_to_message_id: &str, ) -> Result { - if is_no_reply(content) { + if is_no_reply(content) && !self.debug { debug!("ambient: suppressed [NO_REPLY] reply"); return Ok(MessageRef { channel: channel.clone(), @@ -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::>() + .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!( @@ -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 = Arc::new(AmbientCaptureAdapter { inner: Arc::clone(&adapter), + debug: config.debug, }); let dummy_msg_ref = MessageRef { channel: channel_ref.clone(), @@ -639,7 +671,7 @@ async fn ambient_consumer_loop( fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec { let mut blocks = Vec::new(); - // System instruction. + // System instruction from file. blocks.push(ContentBlock::Text { text: instructions.to_string(), }); @@ -650,8 +682,12 @@ fn build_ambient_payload(batch: &[AmbientMessage], instructions: &str) -> Vec). 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'); } @@ -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, diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 6575c21e5..5b5bde4ca 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -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 { @@ -1285,6 +1291,7 @@ impl Default for AmbientConfig { instructions_file: default_instructions_file(), pool: AmbientPoolConfig::default(), discord: AmbientDiscordConfig::default(), + debug: false, } } } diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 71c4054c8..eed04a6b1 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -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(), diff --git a/docs/ambient.md b/docs/ambient.md index 0129f757d..0a3d3a0cd 100644 --- a/docs/ambient.md +++ b/docs/ambient.md @@ -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) @@ -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. |