diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 844cefbab..9fc9dfc08 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,45 @@ 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, + /// 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, + /// 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, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HandoffPayload { + pub text: String, +} + +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) 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 +76,23 @@ 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()); + } else { + tracing::debug!( + "invalid handoff directive ignored; target must be a Discord snowflake" + ); + } + } _ => { tracing::debug!(key = key.trim(), "unknown output directive ignored"); } @@ -416,13 +466,40 @@ 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. + /// 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 + } + + /// 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. + async fn send_handoff( + &self, + _channel: &ChannelRef, + _request: &HandoffRequest, + ) -> Result { + Err(anyhow::anyhow!( + "structured bot-to-bot handoff is not supported by this platform" + )) + } + /// 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 +1010,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 +1060,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 +1106,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 +1185,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 +1199,129 @@ 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") + })?; + 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 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. + let payload_text = final_content.clone(); + 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(), + // 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, + payload: HandoffPayload { text: payload_text }, + }; + adapter.send_handoff(&thread_channel, &request).await + } + .await; + + 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 { + 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) => { + 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 +1410,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 +1499,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 +1527,18 @@ 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. +/// 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}@") + .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 +1547,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 +1774,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 +2364,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 +2518,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/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 609bd7459..7ec2cd386 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; @@ -22,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}; @@ -59,16 +63,26 @@ 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; +const HANDOFF_REPLAY_CACHE_MAX: usize = 4096; // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- pub struct DiscordAdapter { http: Arc, + bot_id: Arc>, + handoff_targets: Arc>>, } impl DiscordAdapter { pub fn new(http: Arc) -> Self { - Self { http } + Self { + http, + bot_id: Arc::new(OnceLock::new()), + handoff_targets: Arc::new(OnceLock::new()), + } } /// Resolve the effective Discord channel ID from a ChannelRef. @@ -78,6 +92,20 @@ 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 { + 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 { @@ -85,7 +113,7 @@ impl ChatAdapter for DiscordAdapter { } fn message_limit(&self) -> usize { - 2000 + DISCORD_MESSAGE_LIMIT } async fn send_message( @@ -94,7 +122,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 +151,11 @@ 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) + // 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) @@ -148,14 +189,72 @@ 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() + } + + 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, + 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()?; + 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 + // once. The JSON envelope is the control-plane payload; no presentation + // edit is used to route it. + let content = build_handoff_content(target_id, &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 +364,66 @@ 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>, + /// Per-source handoff rate window; limits trusted-bot floods within a process. + pub handoff_rates: 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; + } + if events.len() >= HANDOFF_REPLAY_CACHE_MAX { + return false; + } + events.insert(event_id.to_string(), tokio::time::Instant::now()); + 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 + /// 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; + 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 { + 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)`. @@ -485,6 +641,41 @@ 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. 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); + 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( + &msg.content, + request, + msg.author.id.get(), + bot_id.get(), + msg.channel_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; + } + 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; + } + validated_handoff = Some(request.clone()); + } + // 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 +1027,10 @@ impl EventHandler for Handler { return; } - let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids); + 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)); // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { @@ -1389,6 +1583,17 @@ 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()); + 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. @@ -3265,6 +3470,230 @@ 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, 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("\"schema\"") +} + +/// Validate every wire-level handoff invariant before it can bypass ordinary +/// 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 { + 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 channel_id = channel_id.to_string(); + 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 + && 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) + && target_id == bot_id + && first_line == target_mention + && 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 == channel_id + && request + .origin_thread_id + .as_deref() + .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 +} + +#[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: "20".into(), + origin_thread_id: Some("20".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 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)); + 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()); + 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)); + 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 + )); + } + + #[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, 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, 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, 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_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]); + 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(); + 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(); + assert!(content.encode_utf16().count() <= DISCORD_MESSAGE_LIMIT); + + 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] + fn handoff_parser_rejects_non_schema_content() { + assert!(parse_handoff_envelope("<@20>\nnot json").is_none()); + 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 + )); + assert!(is_handoff_candidate("<@20>\n{\"schema\":\"openab.multibot.handoff.v2\"}", true)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -4178,8 +4607,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] @@ -4188,11 +4618,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 --- 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/discord.md b/docs/discord.md index e179d8a1c..7de4b6440 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. 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 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..7b69dd06f 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 +[[handoff:123456789012345678]] +Review complete: tests pass and no security issues found. ``` -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/output-directives.md b/docs/output-directives.md index fe0c6549f..f9694f6c1 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; 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`: ```json 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" diff --git a/src/main.rs b/src/main.rs index ac54067e2..a14b59f0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1344,6 +1344,8 @@ 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()), + handoff_rates: tokio::sync::Mutex::new(std::collections::HashMap::new()), }; let intents = GatewayIntents::GUILD_MESSAGES