From 361ef8feb4dab7c3559ebad8ff2a8aa9f921708c Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:49:56 +0000 Subject: [PATCH 01/17] fix(discord): keep multibot threads responsive --- crates/openab-core/src/adapter.rs | 344 +++++++++++++++++------------- crates/openab-core/src/discord.rs | 272 ++++++++++++++++++++++- src/main.rs | 1 + 3 files changed, 459 insertions(+), 158 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 844cefbab..31a32b0a6 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1,6 +1,6 @@ use anyhow::Result; use async_trait::async_trait; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{error, warn}; @@ -13,12 +13,35 @@ use crate::reactions::StatusReactionController; // --- Output directive parsing --- -/// Parsed directives from agent output header block. -/// Consecutive `[[key:value]]` lines at the start of output are directives. +/// A validated bot-to-bot handoff request. Presentation updates must never be +/// interpreted as this control-plane message; only a complete turn may create it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HandoffRequest { + pub schema: String, + pub source_bot_id: String, + pub target_bot_id: String, + pub event_id: String, + pub origin_channel_id: String, + pub origin_thread_id: Option, + pub origin_message_id: Option, + pub expires_at: u64, + pub hop_count: u8, + pub payload: HandoffPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HandoffPayload { + pub text: String, +} + +pub const MULTIBOT_HANDOFF_SCHEMA: &str = "openab.multibot.handoff.v1"; + #[derive(Default, Debug)] pub struct OutputDirectives { /// Message ID to reply to (Discord: message_reference) pub reply_to: Option, + /// Target Discord bot ID for a complete control-plane handoff. + pub handoff_target_bot_id: Option, } /// Parse `[[key:value]]` directives from the beginning of agent output. @@ -43,6 +66,19 @@ pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { directives.reply_to = Some(v.to_string()); } } + "handoff" => { + let v = value.trim(); + // Discord snowflake IDs are decimal u64 values. Keep the + // directive strict so arbitrary text can never become a + // routing target. + if !v.is_empty() + && v.len() <= 20 + && v.chars().all(|c| c.is_ascii_digit()) + && v.parse::().is_ok() + { + directives.handoff_target_bot_id = Some(v.to_string()); + } + } _ => { tracing::debug!(key = key.trim(), "unknown output directive ignored"); } @@ -423,6 +459,26 @@ pub trait ChatAdapter: Send + Sync + 'static { /// response may stream, but subsequent ones will correctly use send-once. fn use_streaming(&self, other_bot_present: bool) -> bool; + /// Set the identity used as the source of control-plane handoffs. + /// Platforms without bot-to-bot control messages may ignore this. + fn set_bot_identity(&self, _bot_id: &str) {} + + /// Return the configured source identity for a control-plane handoff. + fn bot_identity(&self) -> Option { + None + } + + /// Send one complete, structured bot-to-bot handoff. The default is fail-closed: + /// a platform must explicitly implement this instead of accidentally routing + /// an incomplete presentation message as control input. + async fn send_handoff( + &self, + _channel: &ChannelRef, + _request: &HandoffRequest, + ) -> Result { + Err(anyhow::anyhow!("structured handoff not supported")) + } + /// Whether to send the "…" placeholder message before streaming starts. /// Default: true. Platforms using drafts (e.g. Telegram Rich Messages) can /// return false to suppress the placeholder. @@ -933,12 +989,13 @@ impl AdapterRouter { } } } else if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display( + let display = compose_display( &tool_lines, &text_buf, true, tool_display, - )); + ); + let _ = tx.send(presentation_text(adapter.platform(), &display)); } } AcpEvent::Thinking => { @@ -982,12 +1039,13 @@ impl AdapterRouter { } // Post+edit live update (no-op under native streaming: buf_tx is None). if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display( + let display = compose_display( &tool_lines, &text_buf, true, tool_display, - )); + ); + let _ = tx.send(presentation_text(adapter.platform(), &display)); } } AcpEvent::ToolDone { id, title, status } => { @@ -1027,12 +1085,13 @@ impl AdapterRouter { }); } if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display( + let display = compose_display( &tool_lines, &text_buf, true, tool_display, - )); + ); + let _ = tx.send(presentation_text(adapter.platform(), &display)); } } AcpEvent::ConfigUpdate { options } => { @@ -1105,17 +1164,11 @@ impl AdapterRouter { }; let final_content = markdown::convert_tables(&final_content, table_mode); - let chunks = if adapter.platform() == "discord" { - let mentions = extract_mentions(&final_content); - let mention_reserve = mention_footer_len(&mentions); - let chunks = format::split_message( - &final_content, - message_limit.saturating_sub(mention_reserve), - ); - propagate_mentions_to_chunks(chunks, &mentions, message_limit) - } else { - format::split_message(&final_content, message_limit) - }; + // Final presentation is always inert. A complete handoff, if + // requested, uses the unsanitized payload below and is sent + // through the explicit control-plane API exactly once. + let presentation_content = presentation_text(adapter.platform(), &final_content); + let chunks = format::split_message(&presentation_content, message_limit); // Track delivery health across all final write paths. Any failure // here means the user's view is incomplete; we propagate Err at the // end of the closure so dispatch surfaces set_error (❌) instead of @@ -1125,7 +1178,82 @@ impl AdapterRouter { if assistant_status { let _ = adapter.set_status(&thread_channel, "").await; } - if native { + if let Some(target_bot_id) = directives.handoff_target_bot_id.clone() { + // A handoff is a control-plane operation: it is emitted only + // after the ACP turn is complete, never from a stream delta. + let handoff_result = async { + if adapter.platform() != "discord" { + return Err(anyhow::anyhow!( + "structured handoff is only supported on Discord" + )); + } + let source_bot_id = adapter.bot_identity().ok_or_else(|| { + anyhow::anyhow!("Discord bot identity unavailable for handoff") + })?; + let payload_text = sanitize_discord_mentions(&final_content); + let request = HandoffRequest { + schema: MULTIBOT_HANDOFF_SCHEMA.to_string(), + source_bot_id, + target_bot_id, + event_id: uuid::Uuid::new_v4().to_string(), + origin_channel_id: thread_channel.channel_id.clone(), + origin_thread_id: thread_channel + .parent_id + .as_ref() + .map(|_| thread_channel.channel_id.clone()), + origin_message_id: None, + expires_at: chrono::Utc::now().timestamp().max(0) as u64 + 300, + hop_count: 0, + payload: HandoffPayload { text: payload_text }, + }; + adapter.send_handoff(&thread_channel, &request).await + } + .await; + + match handoff_result { + Ok(_) => { + if let Some(msg) = placeholder_msg { + let _ = adapter.delete_message(&msg).await; + } + } + Err(e) => { + tracing::error!(error = ?e, "structured handoff failed closed"); + delivery_failed = true; + // Keep the human-facing result visible, but it is + // sanitized and therefore cannot become control input. + if let Some(msg) = placeholder_msg { + if let Some(first) = chunks.first() { + if let Err(edit_error) = adapter.edit_message(&msg, first).await { + tracing::warn!(error = ?edit_error, "handoff fallback placeholder edit failed"); + delivery_failed = true; + if let Err(send_error) = + adapter.send_message(&thread_channel, first).await + { + tracing::warn!(error = ?send_error, "handoff fallback first send failed"); + } + } + for chunk in chunks.iter().skip(1) { + if let Err(send_error) = + adapter.send_message(&thread_channel, chunk).await + { + tracing::warn!(error = ?send_error, "handoff fallback overflow send failed"); + delivery_failed = true; + } + } + } + } else { + for chunk in &chunks { + if let Err(send_error) = + adapter.send_message(&thread_channel, chunk).await + { + tracing::warn!(error = ?send_error, "handoff fallback send failed"); + delivery_failed = true; + } + } + } + } + } + } else if native { if let Some(msg) = &native_msg { if !native_pending.is_empty() { if let Err(e) = @@ -1214,34 +1342,6 @@ impl AdapterRouter { tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "delete placeholder failed; placeholder will remain visible"); } } - } else if adapter.platform() == "discord" - && contains_bot_mention(&final_content) - { - // Discord-specific: bot mention detected. Delete placeholder - // and send as new message so Discord emits MESSAGE_CREATE — - // otherwise the mentioned bot won't receive the gateway - // event since MESSAGE_UPDATE skips notifications (#1110). - let mut send_ok = false; - if let Some(first) = chunks.first() { - match adapter.send_message(&thread_channel, first).await { - Ok(_) => { - send_ok = true; - } - Err(e) => { - tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "discord bot-mention first chunk send failed"); - delivery_failed = true; - } - } - } - for chunk in chunks.iter().skip(1) { - if let Err(e) = adapter.send_message(&thread_channel, chunk).await { - tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "streaming overflow chunk send failed"); - delivery_failed = true; - } - } - if send_ok { - let _ = adapter.delete_message(&msg).await; - } } else { // Normal streaming: edit first chunk into placeholder, send rest. // If placeholder is a dummy "draft" ref (no real message), send as @@ -1331,16 +1431,12 @@ impl AdapterRouter { } } -/// Returns true if `content` contains a Discord user/bot mention (`<@123>`, `<@!123>`) -/// or a role mention (`<@&123>`). -/// Used to detect cross-bot mentions so the streaming path can switch from -/// edit (MESSAGE_UPDATE, no mention notification) to delete+send (MESSAGE_CREATE). +#[cfg(test)] fn contains_bot_mention(content: &str) -> bool { let mut i = 0; let bytes = content.as_bytes(); while i + 2 < bytes.len() { if bytes[i] == b'<' && bytes[i + 1] == b'@' { - // Skip optional '!' (nickname mention) or '&' (role mention) let start = if i + 2 < bytes.len() && (bytes[i + 2] == b'!' || bytes[i + 2] == b'&') { @@ -1363,6 +1459,16 @@ fn contains_bot_mention(content: &str) -> bool { false } +/// Neutralize Discord mentions in human-facing presentation text. This is applied +/// to every cumulative streaming edit and every ordinary final send, so a partial +/// output or an overflow chunk can never become a bot-to-bot control event. +fn sanitize_discord_mentions(content: &str) -> String { + content + .replace("<@", "<\u{200b}@") + .replace("@everyone", "@\u{200b}everyone") + .replace("@here", "@\u{200b}here") +} + /// Flatten a tool-call title into a single line safe for inline-code spans. fn sanitize_title(title: &str) -> String { title @@ -1371,6 +1477,14 @@ fn sanitize_title(title: &str) -> String { .replace('`', "'") } +fn presentation_text(platform: &str, content: &str) -> String { + if platform == "discord" { + sanitize_discord_mentions(content) + } else { + content.to_string() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ToolState { Running, @@ -1590,98 +1704,6 @@ fn compose_display( out } -fn extract_mentions(content: &str) -> Vec { - let mut mentions = Vec::new(); - let mut in_fence = false; - - for line in content.split('\n') { - if line.starts_with("```") { - in_fence = !in_fence; - continue; - } - if in_fence { - continue; - } - - let bytes = line.as_bytes(); - let mut i = 0; - while i + 2 < bytes.len() { - if bytes[i] == b'<' && bytes[i + 1] == b'@' { - let (prefix_end, is_role) = if i + 2 < bytes.len() && bytes[i + 2] == b'&' { - (i + 3, true) - } else if i + 2 < bytes.len() && bytes[i + 2] == b'!' { - (i + 3, false) - } else { - (i + 2, false) - }; - if prefix_end < bytes.len() && bytes[prefix_end].is_ascii_digit() { - if let Some(end) = line[prefix_end..].find('>') { - if line[prefix_end..prefix_end + end] - .chars() - .all(|c| c.is_ascii_digit()) - { - let uid = &line[prefix_end..prefix_end + end]; - let normalized = if is_role { - format!("<@&{uid}>") - } else { - format!("<@{uid}>") - }; - if !mentions.contains(&normalized) { - mentions.push(normalized); - } - i = prefix_end + end + 1; - continue; - } - } - } - i = prefix_end; - } else { - i += 1; - } - } - } - mentions -} - -fn mention_footer_len(mentions: &[String]) -> usize { - if mentions.is_empty() { - return 0; - } - 1 + mentions.iter().map(|m| m.len()).sum::() + mentions.len().saturating_sub(1) -} - -fn propagate_mentions_to_chunks( - chunks: Vec, - mentions: &[String], - limit: usize, -) -> Vec { - if mentions.is_empty() || chunks.len() <= 1 { - return chunks; - } - chunks - .into_iter() - .map(|chunk| { - let missing: Vec<&String> = mentions - .iter() - .filter(|m| !chunk.contains(m.as_str())) - .collect(); - if missing.is_empty() { - chunk - } else { - let footer = format!( - "\n{}", - missing.iter().map(|m| m.as_str()).collect::>().join(" ") - ); - if chunk.chars().count() + footer.chars().count() <= limit { - format!("{chunk}{footer}") - } else { - chunk - } - } - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -2272,7 +2294,7 @@ mod tests { #[cfg(test)] mod directive_tests { use super::parse_output_directives; - use super::{classify_empty_turn, SILENT_FAILURE_MSG}; + use super::{classify_empty_turn, presentation_text, sanitize_discord_mentions, SILENT_FAILURE_MSG}; use crate::acp::TurnResult; #[test] @@ -2426,6 +2448,34 @@ mod directive_tests { assert_eq!(content, "看看 [[這個]] 怎麼樣"); } + #[test] + fn parse_handoff_directive_extracts_valid_target() { + let (directives, content) = parse_output_directives( + "[[handoff:1490365068863606784]]\nComplete request", + ); + assert_eq!(directives.handoff_target_bot_id.as_deref(), Some("1490365068863606784")); + assert_eq!(content, "Complete request"); + } + + #[test] + fn parse_handoff_directive_rejects_non_numeric_target() { + let (directives, content) = parse_output_directives("[[handoff:bot-b]]\nRequest"); + assert_eq!(directives.handoff_target_bot_id, None); + assert_eq!(content, "Request"); + } + + #[test] + fn discord_presentation_neutralizes_all_mention_forms() { + let input = "<@123> <@!456> <@&789> @everyone @here"; + let output = sanitize_discord_mentions(input); + assert!(!output.contains("<@123>")); + assert!(!output.contains("<@!456>")); + assert!(!output.contains("<@&789>")); + assert!(!output.contains("@everyone")); + assert!(!output.contains("@here")); + assert_eq!(presentation_text("slack", input), input); + } + // --- classify_empty_turn: adapter-level finalization tests --- #[test] diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 609bd7459..fb02cebee 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -1,6 +1,9 @@ use crate::acp::protocol::{ConfigOption, UsageReport}; use crate::acp::ContentBlock; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; +use crate::adapter::{ + AdapterRouter, ChannelRef, ChatAdapter, HandoffRequest, MessageRef, SenderContext, + MULTIBOT_HANDOFF_SCHEMA, +}; use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::dispatch::DispatchTarget; @@ -10,9 +13,10 @@ use crate::remind::{self, ReminderStore}; use crate::trust::l3_gate_applies; use async_trait::async_trait; use serenity::builder::{ - CreateActionRow, CreateAttachment, CreateButton, CreateCommand, CreateCommandOption, - CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseFollowup, - CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, + CreateActionRow, CreateAllowedMentions, CreateAttachment, CreateButton, CreateCommand, + CreateCommandOption, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, + CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateMessage, + CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditChannel, EditMessage, GetMessages, }; use serenity::http::Http; @@ -64,11 +68,15 @@ const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; pub struct DiscordAdapter { http: Arc, + bot_id: Arc>, } impl DiscordAdapter { pub fn new(http: Arc) -> Self { - Self { http } + Self { + http, + bot_id: Arc::new(OnceLock::new()), + } } /// Resolve the effective Discord channel ID from a ChannelRef. @@ -94,7 +102,17 @@ impl ChatAdapter for DiscordAdapter { content: &str, ) -> anyhow::Result { let ch_id: u64 = Self::resolve_channel(channel).parse()?; - let msg = ChannelId::new(ch_id).say(&self.http, content).await?; + let msg = ChannelId::new(ch_id) + .send_message( + &self.http, + CreateMessage::new() + .content(content) + // Presentation messages are never allowed to ping users, + // roles, @everyone, or @here. Control-plane handoffs use + // the explicit send_handoff path below. + .allowed_mentions(CreateAllowedMentions::new().replied_user(false)), + ) + .await?; Ok(MessageRef { channel: channel.clone(), message_id: msg.id.to_string(), @@ -113,8 +131,9 @@ impl ChatAdapter for DiscordAdapter { // Invalid message ID, fall back to plain send return self.send_message(channel, content).await; } - let builder = serenity::builder::CreateMessage::new() + let builder = CreateMessage::new() .content(content) + .allowed_mentions(CreateAllowedMentions::new().replied_user(false)) .reference_message((ChannelId::new(ch_id), MessageId::new(msg_id))); match ChannelId::new(ch_id) .send_message(&self.http, builder) @@ -148,14 +167,61 @@ impl ChatAdapter for DiscordAdapter { .edit_message( &self.http, MessageId::new(msg_id), - EditMessage::new().content(content), + EditMessage::new() + .content(content) + .allowed_mentions(CreateAllowedMentions::new().replied_user(false)), ) .await?; Ok(()) } - fn use_streaming(&self, other_bot_present: bool) -> bool { - !other_bot_present + fn use_streaming(&self, _other_bot_present: bool) -> bool { + // Discord presentation writes are sanitized and explicitly disable + // allowed mentions, so MESSAGE_UPDATE edits cannot trigger another bot. + // Keep live progress visible even in a multibot thread; only the + // structured handoff path below can emit an executable MESSAGE_CREATE. + true + } + + fn set_bot_identity(&self, bot_id: &str) { + let _ = self.bot_id.set(bot_id.to_string()); + } + + fn bot_identity(&self) -> Option { + self.bot_id.get().cloned() + } + + async fn send_handoff( + &self, + channel: &ChannelRef, + request: &HandoffRequest, + ) -> anyhow::Result { + if request.schema != MULTIBOT_HANDOFF_SCHEMA { + anyhow::bail!("unsupported handoff schema: {}", request.schema); + } + let target_id: u64 = request.target_bot_id.parse()?; + let ch_id: u64 = Self::resolve_channel(channel).parse()?; + let envelope = serde_json::to_string(request)?; + // The target mention is generated from the validated target ID exactly + // once. The JSON envelope is the control-plane payload; no presentation + // edit is used to route it. + let content = format!("<@{target_id}>\n{envelope}"); + let msg = ChannelId::new(ch_id) + .send_message( + &self.http, + CreateMessage::new() + .content(content) + .allowed_mentions( + CreateAllowedMentions::new() + .users([UserId::new(target_id)]) + .replied_user(false), + ), + ) + .await?; + Ok(MessageRef { + channel: channel.clone(), + message_id: msg.id.to_string(), + }) } async fn create_thread( @@ -265,9 +331,32 @@ pub struct Handler { pub reminder_store: ReminderStore, /// Track scheduled reminder IDs to prevent duplicate scheduling on reconnect. pub scheduled_ids: tokio::sync::Mutex>, + /// Recently accepted structured handoff event IDs; prevents replay within a process. + pub handoff_events: tokio::sync::Mutex>, +} + +fn accept_handoff_event_in( + events: &mut HashMap, + event_id: &str, +) -> bool { + const HANDOFF_REPLAY_WINDOW: std::time::Duration = std::time::Duration::from_secs(600); + events.retain(|_, seen_at| seen_at.elapsed() < HANDOFF_REPLAY_WINDOW); + if events.contains_key(event_id) { + return false; + } + events.insert(event_id.to_string(), tokio::time::Instant::now()); + true } impl Handler { + /// Accept each validated control-plane event at most once during its replay + /// window. This is intentionally process-local; the envelope expiry and + /// event ID remain part of the wire contract for a future shared dedupe store. + async fn accept_handoff_event(&self, event_id: &str) -> bool { + let mut events = self.handoff_events.lock().await; + accept_handoff_event_in(&mut events, event_id) + } + /// Check if the bot has participated in a Discord thread, and whether /// other bots have also posted in it. /// Returns `(involved, other_bot_present)`. @@ -485,6 +574,32 @@ impl EventHandler for Handler { .iter() .any(|r| self.allowed_role_ids.contains(&r.get()))); + // Control-plane messages are parsed before ordinary bot gating. A + // schema-looking message that fails validation is dropped rather than + // falling through as an executable prompt. + let handoff = parse_handoff_envelope(&msg.content); + if is_handoff_candidate(&msg.content) { + let valid = handoff.as_ref().is_some_and(|request| { + validate_handoff_envelope( + &msg.content, + request, + msg.author.id.get(), + bot_id.get(), + &self.trusted_bot_ids, + chrono::Utc::now().timestamp().max(0) as u64, + ) + }); + if !valid { + tracing::warn!(author = %msg.author.id, channel = %msg.channel_id, "invalid structured handoff rejected"); + return; + } + let request = handoff.as_ref().expect("validated handoff must be present"); + if !self.accept_handoff_event(&request.event_id).await { + tracing::info!(event_id = %request.event_id, "duplicate structured handoff ignored"); + return; + } + } + // Early-gating optimization for bot messages to avoid unnecessary // async/HTTP thread detection calls when ambient mode is inactive and // the bot would gate it out anyway. (#1197 regression safety) @@ -836,7 +951,10 @@ impl EventHandler for Handler { return; } - let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids); + let prompt = handoff + .as_ref() + .map(|request| request.payload.text.clone()) + .unwrap_or_else(|| resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids)); // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { @@ -1389,6 +1507,11 @@ impl EventHandler for Handler { } async fn ready(&self, ctx: Context, ready: Ready) { + let adapter = self + .adapter + .get_or_init(|| Arc::new(DiscordAdapter::new(ctx.http.clone()))) + .clone(); + adapter.set_bot_identity(&ready.user.id.to_string()); info!(user = %ready.user.name, "discord bot connected"); // Build the shared command list once. @@ -3265,6 +3388,133 @@ fn truncate_to_utf16_budget(body: &str, prefix: &str, suffix: &str, limit: usize truncated } +/// Parse the JSON control envelope carried after the generated target mention. +/// Presentation messages are never parsed here because they do not use this schema. +fn parse_handoff_envelope(content: &str) -> Option { + let (mention, body) = content.split_once('\n')?; + if !mention.starts_with("<@") || !mention.ends_with('>') { + return None; + } + serde_json::from_str(body.trim()).ok() +} + +fn is_handoff_candidate(content: &str) -> bool { + content.contains(MULTIBOT_HANDOFF_SCHEMA) +} + +/// Validate every wire-level handoff invariant before it can bypass ordinary +/// bot-message gating. Invalid candidates fail closed and are never sent to ACP. +fn validate_handoff_envelope( + content: &str, + request: &HandoffRequest, + author_id: u64, + bot_id: u64, + trusted_bot_ids: &HashSet, + now: u64, +) -> bool { + let target_id = match request.target_bot_id.parse::() { + Ok(id) => id, + Err(_) => return false, + }; + let source_id = match request.source_bot_id.parse::() { + Ok(id) => id, + Err(_) => return false, + }; + let target_mention = format!("<@{target_id}>"); + let Some(first_line) = content.lines().next() else { + return false; + }; + + request.schema == MULTIBOT_HANDOFF_SCHEMA + && source_id == author_id + && source_id != bot_id + && trusted_bot_ids.contains(&author_id) + && target_id == bot_id + && first_line == target_mention + && content.matches(&target_mention).count() == 1 + && !content.contains("<@!") + && !content.contains("<@&") + && !content.contains("@everyone") + && !content.contains("@here") + && request.event_id.len() <= 64 + && uuid::Uuid::parse_str(&request.event_id).is_ok() + && request.expires_at > now + && request.expires_at <= now.saturating_add(3600) + && request.hop_count == 0 + && !request.origin_channel_id.is_empty() + && !request.payload.text.is_empty() + && request.payload.text.len() <= 100_000 +} + +#[cfg(test)] +mod handoff_tests { + use super::*; + use crate::adapter::{HandoffPayload, MULTIBOT_HANDOFF_SCHEMA}; + + fn request(target: u64) -> HandoffRequest { + HandoffRequest { + schema: MULTIBOT_HANDOFF_SCHEMA.into(), + source_bot_id: "10".into(), + target_bot_id: target.to_string(), + event_id: uuid::Uuid::nil().to_string(), + origin_channel_id: "thread".into(), + origin_thread_id: Some("thread".into()), + origin_message_id: None, + expires_at: 2_000, + hop_count: 0, + payload: HandoffPayload { text: "complete".into() }, + } + } + + #[test] + fn handoff_requires_exact_target_mention_and_validated_target() { + let req = request(20); + let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); + let mut trusted = HashSet::new(); + trusted.insert(10); + assert!(validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + assert!(!validate_handoff_envelope( + &content.replace("<@20>", "<@21>"), + &req, + 10, + 20, + &trusted, + 1_000 + )); + } + + #[test] + fn handoff_rejects_untrusted_expired_or_forwarded_requests() { + let mut req = request(20); + let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); + let mut trusted = HashSet::new(); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + trusted.insert(10); + req.expires_at = 1_000; + let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + req.expires_at = 2_000; + req.hop_count = 1; + let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + } + + #[test] + fn handoff_event_ids_are_replay_protected() { + let mut events = HashMap::new(); + assert!(accept_handoff_event_in(&mut events, "event-1")); + assert!(!accept_handoff_event_in(&mut events, "event-1")); + assert!(accept_handoff_event_in(&mut events, "event-2")); + } + + #[test] + fn handoff_parser_rejects_non_schema_content() { + assert!(parse_handoff_envelope("<@20>\nnot json").is_none()); + assert!(!is_handoff_candidate("<@20>\nhello")); + assert!(is_handoff_candidate(MULTIBOT_HANDOFF_SCHEMA)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index ac54067e2..4b8bad7e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1344,6 +1344,7 @@ async fn main() -> anyhow::Result<()> { ambient: ambient_dispatcher, reminder_store: reminder_store.clone(), scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()), + handoff_events: tokio::sync::Mutex::new(std::collections::HashMap::new()), }; let intents = GatewayIntents::GUILD_MESSAGES From 8d0d446afae44c2df746c64ee2271e54ef068ee5 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:02:14 +0000 Subject: [PATCH 02/17] docs(discord): clarify multibot streaming behavior --- crates/openab-core/src/adapter.rs | 18 ++++++++++++------ crates/openab-core/src/config.rs | 7 ++++--- crates/openab-core/src/discord.rs | 11 ++++++----- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 31a32b0a6..c726e5ae8 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -36,6 +36,9 @@ pub struct HandoffPayload { pub const MULTIBOT_HANDOFF_SCHEMA: &str = "openab.multibot.handoff.v1"; +/// Parsed directives from the beginning of an agent response. +/// `reply_to` controls platform reply threading; `handoff_target_bot_id` +/// requests a complete, structured control-plane handoff after the turn ends. #[derive(Default, Debug)] pub struct OutputDirectives { /// Message ID to reply to (Discord: message_reference) @@ -452,11 +455,12 @@ pub trait ChatAdapter: Send + Sync + 'static { /// Whether this adapter should use streaming edit (true) or send-once (false). /// `other_bot_present` indicates if another bot has posted in the current thread. - /// Streaming should be disabled in multi-bot threads to avoid edit interference. - /// NOTE: Slight race window exists — the multibot cache is checked before - /// handle_message, so a bot arriving between the check and the response will - /// not be detected until the next message. This is acceptable: the first - /// response may stream, but subsequent ones will correctly use send-once. + /// Most adapters may disable streaming there when edits could interfere with + /// bot routing. Adapters that make presentation writes inert (for example, + /// Discord's sanitized output with mentions disabled) may keep streaming on + /// safely and reserve executable routing for an explicit control-plane path. + /// NOTE: There is a slight race window: a bot arriving between the multibot + /// cache check and `handle_message` may not be detected until the next message. fn use_streaming(&self, other_bot_present: bool) -> bool; /// Set the identity used as the source of control-plane handoffs. @@ -476,7 +480,9 @@ pub trait ChatAdapter: Send + Sync + 'static { _channel: &ChannelRef, _request: &HandoffRequest, ) -> Result { - Err(anyhow::anyhow!("structured handoff not supported")) + Err(anyhow::anyhow!( + "structured bot-to-bot handoff is not supported by this platform" + )) } /// Whether to send the "…" placeholder message before streaming starts. diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 42a5b31f0..4c79233a8 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -609,9 +609,10 @@ pub struct GatewayConfig { /// by default**. By default send-once delivers **only the final answer block** — the text after /// the last tool call — dropping inter-tool narration (the shared default send-once trimming in /// `AdapterRouter::stream_prompt_blocks`, controlled by the platform-agnostic - /// `[reactions] narration_display`). Discord is likewise send-once in multi-bot threads - /// (`use_streaming` = `!other_bot_present`) and gets the same default trimming. Set `true` to - /// stream live and keep the full inter-tool text. + /// `[reactions] narration_display`). Discord keeps sanitized presentation + /// streaming enabled even in multi-bot threads; this setting controls the + /// gateway adapter's streaming behavior. Set `true` to stream live and keep + /// the full inter-tool text on gateway platforms. #[serde(default)] pub streaming: bool, /// Show "…" placeholder at streaming start. Default: true. Set false for platforms using drafts. diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index fb02cebee..96c2208d5 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -4428,8 +4428,9 @@ mod tests { ); } - // --- Per-thread streaming tests (#534) --- - // Streaming ON by default, OFF when another bot is detected in the thread. + // --- Per-thread streaming tests (#534, #1436) --- + // Discord presentation writes are inert, so streaming remains enabled even + // when another bot is present; only explicit structured handoffs route work. /// Single bot thread: streaming enabled. #[test] @@ -4438,11 +4439,11 @@ mod tests { assert!(adapter.use_streaming(false)); } - /// Multi-bot thread: send-once to avoid edit interference. + /// Multi-bot thread: sanitized presentation streaming remains enabled. #[test] - fn discord_no_stream_when_other_bot_present() { + fn discord_streams_when_other_bot_present() { let adapter = super::DiscordAdapter::new(Arc::new(super::Http::new(""))); - assert!(!adapter.use_streaming(true)); + assert!(adapter.use_streaming(true)); } // --- resolve_channel tests --- From b0122fed3160d19bbf154c71933746166fbc2575 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:13 +0000 Subject: [PATCH 03/17] fix(multibot): bind and preserve handoffs --- crates/openab-core/src/adapter.rs | 53 ++++++++++++++++++++++++++++--- crates/openab-core/src/discord.rs | 27 ++++++++++++---- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index c726e5ae8..1c1e85cc6 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -21,7 +21,10 @@ pub struct HandoffRequest { pub source_bot_id: String, pub target_bot_id: String, pub event_id: String, + /// Discord channel that carries the handoff envelope. For a Discord thread, + /// this is the thread-as-channel ID rather than its parent channel ID. pub origin_channel_id: String, + /// The thread-as-channel ID when the originating message was in a thread. pub origin_thread_id: Option, pub origin_message_id: Option, pub expires_at: u64, @@ -80,6 +83,10 @@ pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { && v.parse::().is_ok() { directives.handoff_target_bot_id = Some(v.to_string()); + } else { + tracing::debug!( + "invalid handoff directive ignored; target must be a Discord snowflake" + ); } } _ => { @@ -1203,10 +1210,13 @@ impl AdapterRouter { target_bot_id, event_id: uuid::Uuid::new_v4().to_string(), origin_channel_id: thread_channel.channel_id.clone(), - origin_thread_id: thread_channel - .parent_id - .as_ref() - .map(|_| thread_channel.channel_id.clone()), + // Discord threads are separate channels: when parent_id + // is present, channel_id is the actual thread ID. + origin_thread_id: if thread_channel.parent_id.is_some() { + Some(thread_channel.channel_id.clone()) + } else { + None + }, origin_message_id: None, expires_at: chrono::Utc::now().timestamp().max(0) as u64 + 300, hop_count: 0, @@ -1218,8 +1228,41 @@ impl AdapterRouter { match handoff_result { Ok(_) => { + // Keep the completed human-facing presentation visible + // after routing the structured control message. The + // control-plane handoff is not a replacement for the + // presentation-plane result. if let Some(msg) = placeholder_msg { - let _ = adapter.delete_message(&msg).await; + if let Some(first) = chunks.first() { + if let Err(edit_error) = + adapter.edit_message(&msg, first).await + { + tracing::warn!(error = ?edit_error, "handoff presentation edit failed"); + delivery_failed = true; + if let Err(send_error) = + adapter.send_message(&thread_channel, first).await + { + tracing::warn!(error = ?send_error, "handoff presentation first send failed"); + } + } + for chunk in chunks.iter().skip(1) { + if let Err(send_error) = + adapter.send_message(&thread_channel, chunk).await + { + tracing::warn!(error = ?send_error, "handoff presentation overflow send failed"); + delivery_failed = true; + } + } + } + } else { + for chunk in &chunks { + if let Err(send_error) = + adapter.send_message(&thread_channel, chunk).await + { + tracing::warn!(error = ?send_error, "handoff presentation send failed"); + delivery_failed = true; + } + } } } Err(e) => { diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 96c2208d5..bb1849da2 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -585,6 +585,7 @@ impl EventHandler for Handler { request, msg.author.id.get(), bot_id.get(), + msg.channel_id.get(), &self.trusted_bot_ids, chrono::Utc::now().timestamp().max(0) as u64, ) @@ -3403,12 +3404,16 @@ fn is_handoff_candidate(content: &str) -> bool { } /// Validate every wire-level handoff invariant before it can bypass ordinary -/// bot-message gating. Invalid candidates fail closed and are never sent to ACP. +/// bot-message gating. The sender emits a five-minute expiry; the receiver +/// also caps any accepted future expiry at one hour to bound replay exposure +/// if a sender is buggy. Origin channel/thread metadata is bound to the live +/// Discord channel before the payload can reach ACP. fn validate_handoff_envelope( content: &str, request: &HandoffRequest, author_id: u64, bot_id: u64, + channel_id: u64, trusted_bot_ids: &HashSet, now: u64, ) -> bool { @@ -3421,6 +3426,7 @@ fn validate_handoff_envelope( Err(_) => return false, }; let target_mention = format!("<@{target_id}>"); + let channel_id = channel_id.to_string(); let Some(first_line) = content.lines().next() else { return false; }; @@ -3441,6 +3447,11 @@ fn validate_handoff_envelope( && request.expires_at > now && request.expires_at <= now.saturating_add(3600) && request.hop_count == 0 + && request.origin_channel_id == channel_id + && request + .origin_thread_id + .as_deref() + .map_or(true, |thread_id| thread_id == channel_id.as_str()) && !request.origin_channel_id.is_empty() && !request.payload.text.is_empty() && request.payload.text.len() <= 100_000 @@ -3457,8 +3468,8 @@ mod handoff_tests { source_bot_id: "10".into(), target_bot_id: target.to_string(), event_id: uuid::Uuid::nil().to_string(), - origin_channel_id: "thread".into(), - origin_thread_id: Some("thread".into()), + origin_channel_id: "20".into(), + origin_thread_id: Some("20".into()), origin_message_id: None, expires_at: 2_000, hop_count: 0, @@ -3472,12 +3483,14 @@ mod handoff_tests { let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); let mut trusted = HashSet::new(); trusted.insert(10); - assert!(validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, 21, &trusted, 1_000)); assert!(!validate_handoff_envelope( &content.replace("<@20>", "<@21>"), &req, 10, 20, + 20, &trusted, 1_000 )); @@ -3488,15 +3501,15 @@ mod handoff_tests { let mut req = request(20); let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); let mut trusted = HashSet::new(); - assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); trusted.insert(10); req.expires_at = 1_000; let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); - assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); req.expires_at = 2_000; req.hop_count = 1; let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); - assert!(!validate_handoff_envelope(&content, &req, 10, 20, &trusted, 1_000)); + assert!(!validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); } #[test] From 08c1e5c6105b5882de5b1c6b1de6a2d36575c2f8 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:06:55 +0000 Subject: [PATCH 04/17] fix(discord): bound handoff message size --- crates/openab-core/src/adapter.rs | 6 ++++++ crates/openab-core/src/discord.rs | 26 +++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 1c1e85cc6..08ebb2e19 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -26,8 +26,12 @@ pub struct HandoffRequest { pub origin_channel_id: String, /// The thread-as-channel ID when the originating message was in a thread. pub origin_thread_id: Option, + /// Optional originating Discord message ID; currently omitted when the + /// handoff is emitted after an ACP turn rather than directly from a message event. pub origin_message_id: Option, pub expires_at: u64, + /// Reserved for future forwarding; this protocol currently accepts only + /// first-hop requests (`0`) and rejects forwarded values. pub hop_count: u8, pub payload: HandoffPayload, } @@ -1511,6 +1515,8 @@ fn contains_bot_mention(content: &str) -> bool { /// Neutralize Discord mentions in human-facing presentation text. This is applied /// to every cumulative streaming edit and every ordinary final send, so a partial /// output or an overflow chunk can never become a bot-to-bot control event. +/// The inserted zero-width characters are intentional: Discord renders the text +/// unchanged while raw copied content carries the inert separator. fn sanitize_discord_mentions(content: &str) -> String { content .replace("<@", "<\u{200b}@") diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index bb1849da2..ef0aa7f27 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -63,6 +63,7 @@ fn truncate_for_discord(s: &str, max: usize) -> String { /// Avoid unbounded Discord history exports from very large threads. const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; +const DISCORD_MESSAGE_LIMIT: usize = 2000; // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- @@ -86,6 +87,16 @@ impl DiscordAdapter { } } +fn build_handoff_content(target_id: u64, envelope: &str) -> anyhow::Result { + let content = format!("<@{target_id}>\n{envelope}"); + if content.encode_utf16().count() > DISCORD_MESSAGE_LIMIT { + anyhow::bail!( + "structured handoff exceeds Discord's {DISCORD_MESSAGE_LIMIT}-character message limit" + ); + } + Ok(content) +} + #[async_trait] impl ChatAdapter for DiscordAdapter { fn platform(&self) -> &'static str { @@ -93,7 +104,7 @@ impl ChatAdapter for DiscordAdapter { } fn message_limit(&self) -> usize { - 2000 + DISCORD_MESSAGE_LIMIT } async fn send_message( @@ -205,7 +216,7 @@ impl ChatAdapter for DiscordAdapter { // The target mention is generated from the validated target ID exactly // once. The JSON envelope is the control-plane payload; no presentation // edit is used to route it. - let content = format!("<@{target_id}>\n{envelope}"); + let content = build_handoff_content(target_id, &envelope)?; let msg = ChannelId::new(ch_id) .send_message( &self.http, @@ -576,7 +587,9 @@ impl EventHandler for Handler { // Control-plane messages are parsed before ordinary bot gating. A // schema-looking message that fails validation is dropped rather than - // falling through as an executable prompt. + // falling through as an executable prompt. Bot-turn accounting above + // already applies to every bot-authored message, including accepted + // handoffs, so the configured per-thread cap remains the DoS guard. let handoff = parse_handoff_envelope(&msg.content); if is_handoff_candidate(&msg.content) { let valid = handoff.as_ref().is_some_and(|request| { @@ -3520,6 +3533,13 @@ mod handoff_tests { assert!(accept_handoff_event_in(&mut events, "event-2")); } + #[test] + fn handoff_content_is_bounded_by_discord_message_limit() { + let content = build_handoff_content(20, "payload").unwrap(); + assert!(content.encode_utf16().count() <= DISCORD_MESSAGE_LIMIT); + assert!(build_handoff_content(20, &"x".repeat(DISCORD_MESSAGE_LIMIT)).is_err()); + } + #[test] fn handoff_parser_rejects_non_schema_content() { assert!(parse_handoff_envelope("<@20>\nnot json").is_none()); From 1e1d9ea97242a3bcd0978f4e4119b1e88dfc6cf8 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:08:33 +0000 Subject: [PATCH 05/17] fix(multibot): narrow handoff candidate detection --- crates/openab-core/src/adapter.rs | 7 +++++- crates/openab-core/src/discord.rs | 42 +++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 08ebb2e19..0b5ffb69d 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1207,7 +1207,12 @@ impl AdapterRouter { let source_bot_id = adapter.bot_identity().ok_or_else(|| { anyhow::anyhow!("Discord bot identity unavailable for handoff") })?; - let payload_text = sanitize_discord_mentions(&final_content); + // The control envelope is protected by explicit Discord + // allowed_mentions, so preserve the raw prompt text here. + // Presentation sanitization is only for human-facing output; + // inserting zero-width characters into the control payload + // would corrupt downstream agent context. + let payload_text = final_content.clone(); let request = HandoffRequest { schema: MULTIBOT_HANDOFF_SCHEMA.to_string(), source_bot_id, diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index ef0aa7f27..74b8fdf31 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -591,7 +591,7 @@ impl EventHandler for Handler { // already applies to every bot-authored message, including accepted // handoffs, so the configured per-thread cap remains the DoS guard. let handoff = parse_handoff_envelope(&msg.content); - if is_handoff_candidate(&msg.content) { + if is_handoff_candidate(&msg.content, msg.author.bot) { let valid = handoff.as_ref().is_some_and(|request| { validate_handoff_envelope( &msg.content, @@ -3412,8 +3412,17 @@ fn parse_handoff_envelope(content: &str) -> Option { serde_json::from_str(body.trim()).ok() } -fn is_handoff_candidate(content: &str) -> bool { - content.contains(MULTIBOT_HANDOFF_SCHEMA) +fn is_handoff_candidate(content: &str, author_is_bot: bool) -> bool { + if !author_is_bot { + return false; + } + let Some((mention, body)) = content.split_once('\n') else { + return false; + }; + mention.starts_with("<@") + && mention.ends_with('>') + && body.trim_start().starts_with('{') + && body.contains(MULTIBOT_HANDOFF_SCHEMA) } /// Validate every wire-level handoff invariant before it can bypass ordinary @@ -3443,6 +3452,8 @@ fn validate_handoff_envelope( let Some(first_line) = content.lines().next() else { return false; }; + // Only the generated first line is routable. The JSON payload may contain + // mention-like prompt text; send_handoff explicitly allowlists only target_id. request.schema == MULTIBOT_HANDOFF_SCHEMA && source_id == author_id @@ -3450,11 +3461,6 @@ fn validate_handoff_envelope( && trusted_bot_ids.contains(&author_id) && target_id == bot_id && first_line == target_mention - && content.matches(&target_mention).count() == 1 - && !content.contains("<@!") - && !content.contains("<@&") - && !content.contains("@everyone") - && !content.contains("@here") && request.event_id.len() <= 64 && uuid::Uuid::parse_str(&request.event_id).is_ok() && request.expires_at > now @@ -3492,11 +3498,14 @@ mod handoff_tests { #[test] fn handoff_requires_exact_target_mention_and_validated_target() { - let req = request(20); + let mut req = request(20); let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); let mut trusted = HashSet::new(); trusted.insert(10); assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); + req.payload.text = "quoted @everyone and <@20> remain prompt text".into(); + let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); + assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); assert!(!validate_handoff_envelope(&content, &req, 10, 20, 21, &trusted, 1_000)); assert!(!validate_handoff_envelope( &content.replace("<@20>", "<@21>"), @@ -3543,8 +3552,19 @@ mod handoff_tests { #[test] fn handoff_parser_rejects_non_schema_content() { assert!(parse_handoff_envelope("<@20>\nnot json").is_none()); - assert!(!is_handoff_candidate("<@20>\nhello")); - assert!(is_handoff_candidate(MULTIBOT_HANDOFF_SCHEMA)); + assert!(!is_handoff_candidate("<@20>\nhello", true)); + assert!(!is_handoff_candidate( + &format!("<@20> Please explain {MULTIBOT_HANDOFF_SCHEMA}"), + false + )); + assert!(!is_handoff_candidate( + &format!("<@20> Please explain {MULTIBOT_HANDOFF_SCHEMA}"), + true + )); + assert!(is_handoff_candidate( + &format!("<@20>\n{{\"schema\":\"{MULTIBOT_HANDOFF_SCHEMA}\"}}"), + true + )); } } From 0586ec45037752f5cc0570d4cb227dd2015a1559 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:09:38 +0000 Subject: [PATCH 06/17] fix(discord): rate-limit structured handoffs --- crates/openab-core/src/discord.rs | 44 ++++++++++++++++++++++++++++++- src/main.rs | 1 + 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 74b8fdf31..c1185dc16 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -26,7 +26,7 @@ use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, Reacti use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; use serenity::prelude::*; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::LazyLock; use std::sync::{Arc, OnceLock}; use tracing::{debug, error, info, warn}; @@ -64,6 +64,8 @@ fn truncate_for_discord(s: &str, max: usize) -> String { /// Avoid unbounded Discord history exports from very large threads. const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; const DISCORD_MESSAGE_LIMIT: usize = 2000; +const HANDOFF_RATE_WINDOW: std::time::Duration = std::time::Duration::from_secs(60); +const MAX_HANDOFFS_PER_SOURCE: usize = 10; // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- @@ -344,6 +346,8 @@ pub struct Handler { pub scheduled_ids: tokio::sync::Mutex>, /// Recently accepted structured handoff event IDs; prevents replay within a process. pub handoff_events: tokio::sync::Mutex>, + /// Per-source handoff rate window; limits trusted-bot floods within a process. + pub handoff_rates: tokio::sync::Mutex>>, } fn accept_handoff_event_in( @@ -359,6 +363,25 @@ fn accept_handoff_event_in( true } +fn accept_handoff_rate_in( + rates: &mut HashMap>, + source_id: &str, +) -> bool { + let now = tokio::time::Instant::now(); + let entries = rates.entry(source_id.to_string()).or_default(); + while entries + .front() + .is_some_and(|seen_at| seen_at.elapsed() >= HANDOFF_RATE_WINDOW) + { + entries.pop_front(); + } + if entries.len() >= MAX_HANDOFFS_PER_SOURCE { + return false; + } + entries.push_back(now); + true +} + impl Handler { /// Accept each validated control-plane event at most once during its replay /// window. This is intentionally process-local; the envelope expiry and @@ -368,6 +391,11 @@ impl Handler { accept_handoff_event_in(&mut events, event_id) } + async fn accept_handoff_rate(&self, source_id: &str) -> bool { + let mut rates = self.handoff_rates.lock().await; + accept_handoff_rate_in(&mut rates, source_id) + } + /// Check if the bot has participated in a Discord thread, and whether /// other bots have also posted in it. /// Returns `(involved, other_bot_present)`. @@ -612,6 +640,10 @@ impl EventHandler for Handler { tracing::info!(event_id = %request.event_id, "duplicate structured handoff ignored"); return; } + if !self.accept_handoff_rate(&request.source_bot_id).await { + tracing::warn!(source_bot_id = %request.source_bot_id, "structured handoff rate limit reached"); + return; + } } // Early-gating optimization for bot messages to avoid unnecessary @@ -3542,6 +3574,16 @@ mod handoff_tests { assert!(accept_handoff_event_in(&mut events, "event-2")); } + #[test] + fn handoff_rate_limit_rejects_source_floods() { + let mut rates = HashMap::new(); + for _ in 0..MAX_HANDOFFS_PER_SOURCE { + assert!(accept_handoff_rate_in(&mut rates, "10")); + } + assert!(!accept_handoff_rate_in(&mut rates, "10")); + assert!(accept_handoff_rate_in(&mut rates, "11")); + } + #[test] fn handoff_content_is_bounded_by_discord_message_limit() { let content = build_handoff_content(20, "payload").unwrap(); diff --git a/src/main.rs b/src/main.rs index 4b8bad7e3..a14b59f0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1345,6 +1345,7 @@ async fn main() -> anyhow::Result<()> { reminder_store: reminder_store.clone(), scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()), handoff_events: tokio::sync::Mutex::new(std::collections::HashMap::new()), + handoff_rates: tokio::sync::Mutex::new(std::collections::HashMap::new()), }; let intents = GatewayIntents::GUILD_MESSAGES From b67a5069504ad80296d395a945c5e6661662579d Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:11:08 +0000 Subject: [PATCH 07/17] fix(discord): validate handoff envelopes before dispatch --- crates/openab-core/src/discord.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index c1185dc16..445b4bc51 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -146,7 +146,9 @@ impl ChatAdapter for DiscordAdapter { } let builder = CreateMessage::new() .content(content) - .allowed_mentions(CreateAllowedMentions::new().replied_user(false)) + // Preserve the normal reply notification; presentation content + // itself still has no automatic user/role/everyone mentions. + .allowed_mentions(CreateAllowedMentions::new().replied_user(true)) .reference_message((ChannelId::new(ch_id), MessageId::new(msg_id))); match ChannelId::new(ch_id) .send_message(&self.http, builder) @@ -619,6 +621,7 @@ impl EventHandler for Handler { // already applies to every bot-authored message, including accepted // handoffs, so the configured per-thread cap remains the DoS guard. let handoff = parse_handoff_envelope(&msg.content); + let mut validated_handoff = None; if is_handoff_candidate(&msg.content, msg.author.bot) { let valid = handoff.as_ref().is_some_and(|request| { validate_handoff_envelope( @@ -644,6 +647,7 @@ impl EventHandler for Handler { tracing::warn!(source_bot_id = %request.source_bot_id, "structured handoff rate limit reached"); return; } + validated_handoff = Some(request.clone()); } // Early-gating optimization for bot messages to avoid unnecessary @@ -997,7 +1001,7 @@ impl EventHandler for Handler { return; } - let prompt = handoff + let prompt = validated_handoff .as_ref() .map(|request| request.payload.text.clone()) .unwrap_or_else(|| resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids)); @@ -3454,7 +3458,7 @@ fn is_handoff_candidate(content: &str, author_is_bot: bool) -> bool { mention.starts_with("<@") && mention.ends_with('>') && body.trim_start().starts_with('{') - && body.contains(MULTIBOT_HANDOFF_SCHEMA) + && body.contains("\"schema\"") } /// Validate every wire-level handoff invariant before it can bypass ordinary @@ -3588,7 +3592,11 @@ mod handoff_tests { fn handoff_content_is_bounded_by_discord_message_limit() { let content = build_handoff_content(20, "payload").unwrap(); assert!(content.encode_utf16().count() <= DISCORD_MESSAGE_LIMIT); - assert!(build_handoff_content(20, &"x".repeat(DISCORD_MESSAGE_LIMIT)).is_err()); + + let mut req = request(20); + req.payload.text = "x".repeat(1_800); + let envelope = serde_json::to_string(&req).unwrap(); + assert!(build_handoff_content(20, &envelope).is_err()); } #[test] @@ -3607,6 +3615,7 @@ mod handoff_tests { &format!("<@20>\n{{\"schema\":\"{MULTIBOT_HANDOFF_SCHEMA}\"}}"), true )); + assert!(is_handoff_candidate("<@20>\n{\"schema\":\"openab.multibot.handoff.v2\"}", true)); } } From d570d051a46903fe3b474cbb069bd77c1fb77be5 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:12:43 +0000 Subject: [PATCH 08/17] fix(multibot): authorize outbound handoff targets --- crates/openab-core/src/adapter.rs | 4 ++++ crates/openab-core/src/discord.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 0b5ffb69d..34cdafb53 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -483,6 +483,10 @@ pub trait ChatAdapter: Send + Sync + 'static { None } + /// Configure the outbound control-plane target allowlist. Platforms that do + /// not support structured handoffs may ignore this hook. + fn set_handoff_target_allowlist(&self, _target_bot_ids: &[String]) {} + /// Send one complete, structured bot-to-bot handoff. The default is fail-closed: /// a platform must explicitly implement this instead of accidentally routing /// an incomplete presentation message as control input. diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 445b4bc51..3d0a6a9a2 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -72,6 +72,7 @@ const MAX_HANDOFFS_PER_SOURCE: usize = 10; pub struct DiscordAdapter { http: Arc, bot_id: Arc>, + handoff_targets: Arc>>, } impl DiscordAdapter { @@ -79,6 +80,7 @@ impl DiscordAdapter { Self { http, bot_id: Arc::new(OnceLock::new()), + handoff_targets: Arc::new(OnceLock::new()), } } @@ -89,6 +91,10 @@ impl DiscordAdapter { } } +fn handoff_target_allowed(target_id: u64, targets: Option<&HashSet>) -> bool { + targets.is_some_and(|allowed| allowed.contains(&target_id)) +} + fn build_handoff_content(target_id: u64, envelope: &str) -> anyhow::Result { let content = format!("<@{target_id}>\n{envelope}"); if content.encode_utf16().count() > DISCORD_MESSAGE_LIMIT { @@ -206,6 +212,14 @@ impl ChatAdapter for DiscordAdapter { self.bot_id.get().cloned() } + fn set_handoff_target_allowlist(&self, target_bot_ids: &[String]) { + let targets: HashSet = target_bot_ids + .iter() + .filter_map(|id| id.parse::().ok()) + .collect(); + let _ = self.handoff_targets.set(targets); + } + async fn send_handoff( &self, channel: &ChannelRef, @@ -215,6 +229,9 @@ impl ChatAdapter for DiscordAdapter { anyhow::bail!("unsupported handoff schema: {}", request.schema); } let target_id: u64 = request.target_bot_id.parse()?; + if !handoff_target_allowed(target_id, self.handoff_targets.get()) { + anyhow::bail!("handoff target is not in the configured trusted bot allowlist"); + } let ch_id: u64 = Self::resolve_channel(channel).parse()?; let envelope = serde_json::to_string(request)?; // The target mention is generated from the validated target ID exactly @@ -1562,6 +1579,12 @@ impl EventHandler for Handler { .get_or_init(|| Arc::new(DiscordAdapter::new(ctx.http.clone()))) .clone(); adapter.set_bot_identity(&ready.user.id.to_string()); + let handoff_targets: Vec = self + .trusted_bot_ids + .iter() + .map(u64::to_string) + .collect(); + adapter.set_handoff_target_allowlist(&handoff_targets); info!(user = %ready.user.name, "discord bot connected"); // Build the shared command list once. @@ -3578,6 +3601,14 @@ mod handoff_tests { assert!(accept_handoff_event_in(&mut events, "event-2")); } + #[test] + fn handoff_target_requires_configured_allowlist() { + let allowed = HashSet::from([20_u64]); + assert!(handoff_target_allowed(20, Some(&allowed))); + assert!(!handoff_target_allowed(21, Some(&allowed))); + assert!(!handoff_target_allowed(20, None)); + } + #[test] fn handoff_rate_limit_rejects_source_floods() { let mut rates = HashMap::new(); From cfefe4aea64206787b6569078b6a66f90fb38908 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:14:28 +0000 Subject: [PATCH 09/17] fix(multibot): cap replay cache growth --- crates/openab-core/src/discord.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 3d0a6a9a2..e9456d92c 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -66,6 +66,7 @@ const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; const DISCORD_MESSAGE_LIMIT: usize = 2000; const HANDOFF_RATE_WINDOW: std::time::Duration = std::time::Duration::from_secs(60); const MAX_HANDOFFS_PER_SOURCE: usize = 10; +const HANDOFF_REPLAY_CACHE_MAX: usize = 4096; // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- @@ -378,6 +379,9 @@ fn accept_handoff_event_in( if events.contains_key(event_id) { return false; } + if events.len() >= HANDOFF_REPLAY_CACHE_MAX { + return false; + } events.insert(event_id.to_string(), tokio::time::Instant::now()); true } @@ -407,7 +411,12 @@ impl Handler { /// event ID remain part of the wire contract for a future shared dedupe store. async fn accept_handoff_event(&self, event_id: &str) -> bool { let mut events = self.handoff_events.lock().await; - accept_handoff_event_in(&mut events, event_id) + let already_seen = events.contains_key(event_id); + let accepted = accept_handoff_event_in(&mut events, event_id); + if !accepted && !already_seen && events.len() >= HANDOFF_REPLAY_CACHE_MAX { + tracing::warn!("structured handoff replay cache is full; rejecting new event"); + } + accepted } async fn accept_handoff_rate(&self, source_id: &str) -> bool { @@ -3601,6 +3610,15 @@ mod handoff_tests { assert!(accept_handoff_event_in(&mut events, "event-2")); } + #[test] + fn handoff_replay_cache_has_a_hard_cardinality_cap() { + let mut events = HashMap::new(); + for index in 0..HANDOFF_REPLAY_CACHE_MAX { + assert!(accept_handoff_event_in(&mut events, &format!("event-{index}"))); + } + assert!(!accept_handoff_event_in(&mut events, "event-over-cap")); + } + #[test] fn handoff_target_requires_configured_allowlist() { let allowed = HashSet::from([20_u64]); From 3fb80cd8d33358e1a6910c6a3ce66c9748b3df26 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:18:46 +0000 Subject: [PATCH 10/17] docs(discord): document multibot handoffs and streaming --- docs/discord.md | 27 ++++++++++++++++----------- docs/multi-agent.md | 26 +++++++++++--------------- docs/platforms/schema/discord.toml | 8 ++++---- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/docs/discord.md b/docs/discord.md index e179d8a1c..53ac55d62 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -263,16 +263,16 @@ No configuration is needed — video forwarding is always enabled. OpenAB uses **edit-streaming** on Discord — the bot sends a placeholder message and updates it every 1.5 seconds as tokens arrive, giving a live typing effect. -Streaming is decided **per-thread**, not globally: +Streaming remains enabled during multi-bot conversations so long-running work stays visible: | Thread state | Streaming | |---|---| | Single bot + human | ✅ ON — live edit updates | -| 2+ bots in thread | ❌ OFF — send-once to avoid edit interference | +| 2+ bots in thread | ✅ ON — each bot updates its own presentation message | -When a second bot posts in a thread, streaming automatically switches off for that thread. This prevents multiple bots from editing placeholder messages simultaneously, which causes visual glitches on Discord. +Presentation output is sanitized to prevent ordinary model text from notifying arbitrary Discord users, roles, or everyone. The structured handoff path is separate: an output directive such as `[[handoff:123456789012345678]]` emits a human-visible control message only when the target is in the configured `trusted_bot_ids` allowlist. The handoff target receives the raw structured payload; the human-facing presentation remains sanitized. -No configuration needed — this is automatic based on multibot detection. +No configuration is needed for streaming. Configure `trusted_bot_ids` when enabling bot-to-bot handoffs so outbound targets are explicitly authorized. --- @@ -344,8 +344,13 @@ To enable bots to collaborate (e.g. code review → deploy handoff): # Bot that receives bot messages [discord] allow_bot_messages = "mentions" +trusted_bot_ids = ["123456789012345678"] # explicit outbound/inbound bot allowlist ``` +For a structured handoff, the producing agent emits `[[handoff:]]` in its output. The Discord adapter only sends that control message when the target ID is in `trusted_bot_ids` and is not the current bot. Ordinary presentation text is kept human-readable but its Discord mentions are suppressed; do not use a plain `@BotName` sentence as the handoff protocol. + +A handoff control message is visible in the channel because Discord does not provide a hidden bot-only message type. Its payload is accepted only after schema, source, target, channel/thread provenance, expiry, hop, replay, and rate-limit checks. + ### Bot turn limits To prevent runaway bot-to-bot loops, OpenAB enforces two layers of protection: @@ -362,21 +367,21 @@ Warning messages are sent exactly once (on the exact threshold hit) to prevent w max_bot_turns = 200 # default is 100 ``` -### Ice-breaking: teaching bots who's in the room +### Ice-breaking: identifying bots in the room -Since user mentions are preserved as raw `<@UID>`, bots need a UID→name mapping to know who is who. Add an ice-breaking greeting to each bot's system prompt or context entry: +Bots can still include Discord IDs in their context to identify participants, but ordinary presentation mentions are sanitized and do not notify arbitrary users or roles. For bot-to-bot routing, use the structured directive and configure an explicit target allowlist instead of relying on a plain `@BotName` sentence: ``` We have 3 participants in this room: -MY_NICIKNAME <@MY_NAME> -BOT1_NICKNAME <@BOT1> -BOT2_NICKNAME <@BOT2> +MY_NICKNAME <@MY_ID> +BOT1_NICKNAME <@BOT1_ID> +BOT2_NICKNAME <@BOT2_ID> -Always use <@UID> format to mention someone in your messages. +For a structured handoff, emit [[handoff:]] only for a configured trusted bot. ``` -This lets each bot build the mapping in its own context from the start and correctly mention others using `<@UID>`. +The target ID must be listed in `trusted_bot_ids` on the sending adapter. This makes the routing decision explicit and prevents model-generated numeric IDs from becoming arbitrary notification targets. See [multi-agent.md](multi-agent.md) for detailed examples. diff --git a/docs/multi-agent.md b/docs/multi-agent.md index 642f96818..e989934d5 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -78,24 +78,20 @@ Use `"all"` only when bots need to react to each other's messages without explic ### Example: Code Review → Deploy handoff +The review agent can request a structured handoff by emitting the target deploy bot's Discord ID. Configure the same trusted IDs on the participating bots: + +```toml +[discord] +allow_bot_messages = "mentions" +trusted_bot_ids = ["123456789012345678"] ``` -┌──────────────────────────────────────────────────────────┐ -│ Discord Channel #dev │ -│ │ -│ 👤 User: "Review this PR and deploy if it looks good" │ -│ │ │ -│ ▼ │ -│ 🤖 Kiro (allow_bot_messages = "off"): │ -│ "LGTM — tests pass, no security issues. │ -│ @DeployBot please deploy to staging." │ -│ │ │ -│ ▼ │ -│ 🤖 Deploy Bot (allow_bot_messages = "mentions"): │ -│ "Deploying to staging... ✅ Done." │ -└──────────────────────────────────────────────────────────┘ + +```text +Review complete: tests pass and no security issues found. +[[handoff:123456789012345678]] ``` -Note: the review bot doesn't need `allow_bot_messages` enabled — only the bot that needs to *receive* bot messages does. +The adapter turns the directive into a structured control message addressed to the allowlisted target. The target bot validates the envelope before placing its raw payload in the ACP prompt, while the accompanying presentation remains visible to humans with ordinary Discord mentions suppressed. If the target is not allowlisted, the directive fails closed and only the sanitized presentation is delivered. ### Helm values diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml index 44bdc80bf..3bfd147db 100644 --- a/docs/platforms/schema/discord.toml +++ b/docs/platforms/schema/discord.toml @@ -131,7 +131,7 @@ pr = "" [[openab_features]] feature = "streaming" status = "implemented" -note = "Post-then-edit, not native. use_streaming returns true only when no other bot is present (!other_bot_present); the router consults it at dispatch. uses_native_streaming stays default false, so the trait's native stream methods only hit their edit-based fallbacks." +note = "Post-then-edit, not native. use_streaming remains enabled for single-bot and multibot threads so long-running work stays visible; each bot edits its own presentation message. Presentation mentions are sanitized, while structured handoffs use an explicit trusted_bot_ids target allowlist. uses_native_streaming stays default false, so the trait native stream methods only hit their edit-based fallbacks." source = ["crates/openab-core/src/discord.rs#use_streaming", "crates/openab-core/src/adapter.rs#uses_native_streaming"] pr = "#534" @@ -215,7 +215,7 @@ pr = "" [[openab_features]] feature = "multibot" status = "implemented" -note = "Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; trusted_bot_ids + @mention admits handoff regardless of allow_bot_messages." +note = "Early other-bot detection is cached (disk-persisted, irreversible); multibot threads keep edit-streaming, gate user replies via MultibotMentions, and enforce bot-turn limits. Structured handoffs use [[handoff:]] and require the target to be in trusted_bot_ids; presentation mentions are suppressed." source = ["crates/openab-core/src/discord.rs#MultibotCache", "crates/openab-core/src/discord.rs#BotTurnTracker"] pr = "" @@ -258,7 +258,7 @@ source = "crates/openab-core/src/discord.rs#BotTurnTracker" date = "2026-07-04" title = "Multibot detection is irreversible & disk-cached" note = """ -Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. This flips streaming off (the edit-loop interferes across bots) and can require @mention under MultibotMentions. +Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. Multibot mode keeps presentation streaming enabled and can require @mention under MultibotMentions. Ordinary presentation mentions are sanitized; structured handoffs are separately validated and restricted to trusted_bot_ids. """ kind = "intrinsic" source = "crates/openab-core/src/discord.rs#MultibotCache" @@ -267,7 +267,7 @@ source = "crates/openab-core/src/discord.rs#MultibotCache" date = "2026-07-04" title = "Streaming is post-then-edit, not native" note = """ -Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming disables this whenever another bot is present, to avoid edit interference. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks. +Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming remains enabled when another bot is present, so each bot can keep its own long-running presentation visible. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks. """ kind = "openab_decision" source = "crates/openab-core/src/discord.rs#use_streaming" From 02424ca54ade7a9ca4491a06d22655785b85478d Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:25:39 +0000 Subject: [PATCH 11/17] fix(multibot): reject self handoff targets --- crates/openab-core/src/adapter.rs | 8 +++++++- docs/config-reference.md | 2 +- docs/output-directives.md | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 34cdafb53..9fc9dfc08 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1211,8 +1211,14 @@ impl AdapterRouter { let source_bot_id = adapter.bot_identity().ok_or_else(|| { anyhow::anyhow!("Discord bot identity unavailable for handoff") })?; + if target_bot_id == source_bot_id { + return Err(anyhow::anyhow!( + "structured handoff cannot target the sending bot" + )); + } // The control envelope is protected by explicit Discord - // allowed_mentions, so preserve the raw prompt text here. + // allowed_mentions, so preserve the formatted final content + // here (after table conversion, before mention sanitization). // Presentation sanitization is only for human-facing output; // inserting zero-width characters into the control payload // would corrupt downstream agent context. diff --git a/docs/config-reference.md b/docs/config-reference.md index d8aca03b0..9834b58b8 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -78,7 +78,7 @@ Discord adapter. Requires a Discord bot token. | `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. | | `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. | | `allow_bot_messages` | string | `"off"` | `"off"` — ignore all bot messages. `"mentions"` — only process bot messages that @mention this bot. `"all"` — process all bot messages (capped by `max_bot_turns`). | -| `trusted_bot_ids` | string[] | `[]` | When non-empty, only these bot IDs pass the bot gate. Empty = any bot (mode permitting). **Admission override:** a trusted bot that @mentions this bot bypasses `allow_bot_messages` mode entirely (treated as human @mention, can pull bot into threads). | +| `trusted_bot_ids` | string[] | `[]` | When non-empty, only these bot IDs pass the bot gate. Empty = any bot (mode permitting). **Admission override:** a trusted bot that @mentions this bot bypasses `allow_bot_messages` mode entirely (treated as human @mention, can pull bot into threads). For Discord structured handoffs, this list is also the outbound target allowlist: `[[handoff:BOT_ID]]` fails closed unless `BOT_ID` is listed. | | `allow_user_messages` | string | `"multibot-mentions"` | `"multibot-mentions"` — like `"involved"`, but require @mention once another bot has posted in the thread (recommended for multi-bot deployments). `"involved"` — reply in threads bot has participated in without @mention; channel messages require @mention; DMs always process. `"mentions"` — always require @mention. | | `allow_dm` | bool | `false` | `true` = respond to Discord DMs; `false` = ignore DMs. `allowed_users` still applies in DMs. Each DM user consumes one session slot. | | `max_bot_turns` | u32 | `100` | Max consecutive bot turns per thread before throttling (soft limit). Human message resets the counter. A compiled-in hard cap of 1000 consecutive bot messages is always enforced. | diff --git a/docs/output-directives.md b/docs/output-directives.md index fe0c6549f..3badbd894 100644 --- a/docs/output-directives.md +++ b/docs/output-directives.md @@ -39,6 +39,17 @@ Here is my reply to that specific message. - Invalid/non-existent message ID: silently falls back to plain send - Works in both streaming and send-once modes +### `handoff` (Discord only) + +Request a structured bot-to-bot handoff after the current ACP turn completes: + +``` +[[handoff:123456789012345678]] +Review complete; deploy this change to staging. +``` + +**Value**: a decimal Discord bot snowflake (`u64`, at most 20 digits). The directive is stripped from presentation output. On Discord, OpenAB emits a structured control message only when the target ID is present in `trusted_bot_ids` and is not the sending bot; otherwise it fails closed and delivers only the sanitized presentation. The control payload is subject to Discord's 2,000 UTF-16-code-unit message limit, including the target mention and JSON envelope, so practical payload capacity is lower than 2,000 characters. Receiving bots validate schema, trust, target, provenance, expiry, hop count, replay, rate, and payload bounds before dispatching it to ACP. + **How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`: ```json From 5dceedb43c2b2332caa7c6d9ceca71177c2835ec Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:25:46 +0000 Subject: [PATCH 12/17] docs(discord): explain fail-closed handoff candidates --- docs/discord.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/discord.md b/docs/discord.md index 53ac55d62..7de4b6440 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -349,7 +349,7 @@ trusted_bot_ids = ["123456789012345678"] # explicit outbound/inbound bot allowl For a structured handoff, the producing agent emits `[[handoff:]]` in its output. The Discord adapter only sends that control message when the target ID is in `trusted_bot_ids` and is not the current bot. Ordinary presentation text is kept human-readable but its Discord mentions are suppressed; do not use a plain `@BotName` sentence as the handoff protocol. -A handoff control message is visible in the channel because Discord does not provide a hidden bot-only message type. Its payload is accepted only after schema, source, target, channel/thread provenance, expiry, hop, replay, and rate-limit checks. +A handoff control message is visible in the channel because Discord does not provide a hidden bot-only message type. Its payload is accepted only after schema, source, target, channel/thread provenance, expiry, hop, replay, and rate-limit checks. A bot-authored message that matches the envelope shape but fails validation is dropped rather than passed to the normal ACP prompt path; this fail-closed behavior prevents malformed control input from becoming ordinary agent input. ### Bot turn limits From 4343f869988c46c65a2250754687ceadb4fedd8c Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:30:25 +0000 Subject: [PATCH 13/17] docs(multibot): place handoff directive first --- docs/multi-agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/multi-agent.md b/docs/multi-agent.md index e989934d5..7b69dd06f 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -87,8 +87,8 @@ trusted_bot_ids = ["123456789012345678"] ``` ```text -Review complete: tests pass and no security issues found. [[handoff:123456789012345678]] +Review complete: tests pass and no security issues found. ``` The adapter turns the directive into a structured control message addressed to the allowlisted target. The target bot validates the envelope before placing its raw payload in the ACP prompt, while the accompanying presentation remains visible to humans with ordinary Discord mentions suppressed. If the target is not allowlisted, the directive fails closed and only the sanitized presentation is delivered. From 8774c9a60ced187d6bae3fd53099fe5be5cd43a2 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:36:42 +0000 Subject: [PATCH 14/17] docs(handoff): document invalid directives and receive bounds --- docs/output-directives.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/output-directives.md b/docs/output-directives.md index 3badbd894..f9694f6c1 100644 --- a/docs/output-directives.md +++ b/docs/output-directives.md @@ -48,7 +48,7 @@ Request a structured bot-to-bot handoff after the current ACP turn completes: Review complete; deploy this change to staging. ``` -**Value**: a decimal Discord bot snowflake (`u64`, at most 20 digits). The directive is stripped from presentation output. On Discord, OpenAB emits a structured control message only when the target ID is present in `trusted_bot_ids` and is not the sending bot; otherwise it fails closed and delivers only the sanitized presentation. The control payload is subject to Discord's 2,000 UTF-16-code-unit message limit, including the target mention and JSON envelope, so practical payload capacity is lower than 2,000 characters. Receiving bots validate schema, trust, target, provenance, expiry, hop count, replay, rate, and payload bounds before dispatching it to ACP. +**Value**: a decimal Discord bot snowflake (`u64`, at most 20 digits). The directive is stripped from presentation output; directive-shaped `handoff` lines with invalid values are also consumed and ignored, with a debug trace, rather than being forwarded as content. On Discord, OpenAB emits a structured control message only when the target ID is present in `trusted_bot_ids` and is not the sending bot; otherwise it fails closed and delivers only the sanitized presentation. The control payload is subject to Discord's 2,000 UTF-16-code-unit message limit, including the target mention and JSON envelope, so practical payload capacity is lower than 2,000 characters. Receiving bots additionally enforce a 100,000-byte `payload.text` defense-in-depth bound, then validate schema, trust, target, provenance, expiry, hop count, replay, and rate before dispatching it to ACP. **How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`: From 614317f5061c12e1487b9f6ecdbb4e42c7561110 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:43:10 +0000 Subject: [PATCH 15/17] fix(discord): reject non-canonical handoff sources --- crates/openab-core/src/discord.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index e9456d92c..dd4b1f09b 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -3524,6 +3524,7 @@ fn validate_handoff_envelope( // mention-like prompt text; send_handoff explicitly allowlists only target_id. request.schema == MULTIBOT_HANDOFF_SCHEMA + && request.source_bot_id == source_id.to_string() && source_id == author_id && source_id != bot_id && trusted_bot_ids.contains(&author_id) @@ -3571,6 +3572,18 @@ mod handoff_tests { let mut trusted = HashSet::new(); trusted.insert(10); assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); + let mut padded_source = req.clone(); + padded_source.source_bot_id = "00010".into(); + let padded_content = format!("<@20>\n{}", serde_json::to_string(&padded_source).unwrap()); + assert!(!validate_handoff_envelope( + &padded_content, + &padded_source, + 10, + 20, + 20, + &trusted, + 1_000 + )); req.payload.text = "quoted @everyone and <@20> remain prompt text".into(); let content = format!("<@20>\n{}", serde_json::to_string(&req).unwrap()); assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); From 49abc6f98d9a31c7b80d90a2519736a7d1265e17 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:48:25 +0000 Subject: [PATCH 16/17] fix(discord): canonicalize handoff target IDs --- crates/openab-core/src/discord.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index dd4b1f09b..8f669ce97 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -3525,6 +3525,7 @@ fn validate_handoff_envelope( request.schema == MULTIBOT_HANDOFF_SCHEMA && request.source_bot_id == source_id.to_string() + && request.target_bot_id == target_id.to_string() && source_id == author_id && source_id != bot_id && trusted_bot_ids.contains(&author_id) @@ -3572,6 +3573,18 @@ mod handoff_tests { let mut trusted = HashSet::new(); trusted.insert(10); assert!(validate_handoff_envelope(&content, &req, 10, 20, 20, &trusted, 1_000)); + let mut padded_target = req.clone(); + padded_target.target_bot_id = "020".into(); + let padded_target_content = format!("<@20>\n{}", serde_json::to_string(&padded_target).unwrap()); + assert!(!validate_handoff_envelope( + &padded_target_content, + &padded_target, + 10, + 20, + 20, + &trusted, + 1_000 + )); let mut padded_source = req.clone(); padded_source.source_bot_id = "00010".into(); let padded_content = format!("<@20>\n{}", serde_json::to_string(&padded_source).unwrap()); From 82a79f7b8727ae23c959ed66d103014d989afea3 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Wed, 22 Jul 2026 20:47:31 -0400 Subject: [PATCH 17/17] fix(clippy): use is_none_or instead of map_or(true, ...) --- crates/openab-core/src/discord.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 8f669ce97..7ec2cd386 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -3540,7 +3540,7 @@ fn validate_handoff_envelope( && request .origin_thread_id .as_deref() - .map_or(true, |thread_id| thread_id == channel_id.as_str()) + .is_none_or(|thread_id| thread_id == channel_id.as_str()) && !request.origin_channel_id.is_empty() && !request.payload.text.is_empty() && request.payload.text.len() <= 100_000