diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index c0cb8971..24b668c3 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -14,7 +14,7 @@ use dashmap::DashMap; use futures::StreamExt; use openfang_types::agent::AgentId; use openfang_types::approval::ApprovalRequest; -use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat}; +use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat, PrefixStyle}; use openfang_types::message::ContentBlock; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -493,6 +493,69 @@ fn channel_type_str(channel: &crate::types::ChannelType) -> &str { } } +/// Wrap an outbound message with the responding agent's name according to +/// `style`. +/// +/// Applied once at the top of the final response text (never per streaming +/// chunk). If the text already starts with the exact bracketed agent label +/// (e.g. the agent echoed its own name, or an inner agent already prefixed a +/// delegated reply), the wrap is skipped to keep things idempotent. +/// +/// Per-platform native identity features (Slack `username` override, Discord +/// embed `author`, Telegram `From:` in rich messages) are intentionally not +/// handled here — that is a follow-up. +pub(crate) fn apply_agent_prefix(style: PrefixStyle, agent_name: &str, text: &str) -> String { + if matches!(style, PrefixStyle::Off) || agent_name.is_empty() { + return text.to_string(); + } + let bracket = format!("[{agent_name}]"); + let bold = format!("**[{agent_name}]**"); + if text.starts_with(&bracket) || text.starts_with(&bold) { + return text.to_string(); + } + match style { + PrefixStyle::Off => text.to_string(), + PrefixStyle::Bracket => format!("{bracket} {text}"), + PrefixStyle::BoldBracket => format!("{bold} {text}"), + } +} + +/// Look up an agent's display name by id. +/// +/// Returns `None` if the kernel can't list agents or the id is not currently +/// known. Only called when `prefix_agent_name` is enabled, so the extra +/// `list_agents()` round-trip is pay-per-use. +async fn resolve_agent_name(handle: &Arc, id: AgentId) -> Option { + handle + .list_agents() + .await + .ok()? + .into_iter() + .find_map(|(aid, name)| (aid == id).then_some(name)) +} + +/// Apply `prefix_agent_name` to an outbound agent response if configured. +/// +/// Safe to call on every success path: resolves the agent name lazily and +/// returns the original text unchanged when the style is `Off`. +async fn maybe_prefix_response( + handle: &Arc, + overrides: Option<&ChannelOverrides>, + agent_id: AgentId, + text: String, +) -> String { + let style = overrides + .map(|o| o.prefix_agent_name) + .unwrap_or(PrefixStyle::Off); + if matches!(style, PrefixStyle::Off) { + return text; + } + match resolve_agent_name(handle, agent_id).await { + Some(name) => apply_agent_prefix(style, &name, &text), + None => text, + } +} + /// Send a response, applying output formatting and optional threading. async fn send_response( adapter: &dyn ChannelAdapter, @@ -733,6 +796,10 @@ async fn dispatch_message( .iter() .any(|b| matches!(b, ContentBlock::Image { .. })) { + let prefix_style = overrides + .as_ref() + .map(|o| o.prefix_agent_name) + .unwrap_or(PrefixStyle::Off); // We have actual image data — send as structured blocks for vision dispatch_with_blocks( blocks, @@ -745,6 +812,7 @@ async fn dispatch_message( thread_id, output_format, lifecycle_reactions, + prefix_style, ) .await; return; @@ -935,6 +1003,7 @@ async fn dispatch_message( // Auto-reply check — if enabled, the engine decides whether to process this message. // If auto-reply is enabled but suppressed for this message, skip agent call entirely. if let Some(reply) = handle.check_auto_reply(agent_id, &text).await { + let reply = maybe_prefix_response(handle, overrides.as_ref(), agent_id, reply).await; send_response(adapter, &message.sender, reply, thread_id, output_format).await; handle .record_delivery( @@ -990,6 +1059,8 @@ async fn dispatch_message( if lifecycle_reactions { send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await; } + let response = + maybe_prefix_response(handle, overrides.as_ref(), agent_id, response).await; send_response(adapter, &message.sender, response, thread_id, output_format).await; handle .record_delivery( @@ -1019,6 +1090,9 @@ async fn dispatch_message( ) .await; } + let response = + maybe_prefix_response(handle, overrides.as_ref(), new_id, response) + .await; send_response(adapter, &message.sender, response, thread_id, output_format) .await; handle @@ -1306,6 +1380,7 @@ async fn dispatch_with_blocks( thread_id: Option<&str>, output_format: OutputFormat, lifecycle_reactions: bool, + prefix_style: PrefixStyle, ) { // Route to agent (same logic as text path) let agent_id = router.resolve( @@ -1382,11 +1457,23 @@ async fn dispatch_with_blocks( typing_task.abort(); + // Resolve agent name once (only if the prefix feature is on) and reuse for + // both the first response and any re-resolved retry. + let prefix_name = if matches!(prefix_style, PrefixStyle::Off) { + None + } else { + resolve_agent_name(handle, agent_id).await + }; + match result { Ok(response) => { if lifecycle_reactions { send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await; } + let response = match &prefix_name { + Some(name) => apply_agent_prefix(prefix_style, name, &response), + None => response, + }; send_response(adapter, &message.sender, response, thread_id, output_format).await; handle .record_delivery( @@ -1416,6 +1503,15 @@ async fn dispatch_with_blocks( ) .await; } + let retry_name = if matches!(prefix_style, PrefixStyle::Off) { + None + } else { + resolve_agent_name(handle, new_id).await + }; + let response = match &retry_name { + Some(name) => apply_agent_prefix(prefix_style, name, &response), + None => response, + }; send_response(adapter, &message.sender, response, thread_id, output_format) .await; handle @@ -2041,6 +2137,136 @@ mod tests { assert_eq!(detect_image_magic(&[]), None); } + #[test] + fn test_apply_agent_prefix_off_is_identity() { + let text = "hello world"; + let out = apply_agent_prefix(PrefixStyle::Off, "coder", text); + assert_eq!(out, text); + // Ensure no reallocation surprise: the output must equal the input byte-for-byte. + assert_eq!(out.as_bytes(), text.as_bytes()); + } + + #[test] + fn test_apply_agent_prefix_bracket() { + let out = apply_agent_prefix( + PrefixStyle::Bracket, + "platform-architect", + "Here's my take.", + ); + assert_eq!(out, "[platform-architect] Here's my take."); + } + + #[test] + fn test_apply_agent_prefix_bold_bracket() { + let out = apply_agent_prefix(PrefixStyle::BoldBracket, "coder", "All green."); + assert_eq!(out, "**[coder]** All green."); + } + + #[test] + fn test_apply_agent_prefix_idempotent_bracket() { + // If the response already carries our bracket label, don't double-wrap. + let already = "[coder] already prefixed"; + let out = apply_agent_prefix(PrefixStyle::Bracket, "coder", already); + assert_eq!(out, already); + } + + #[test] + fn test_apply_agent_prefix_idempotent_bold_bracket() { + let already = "**[coder]** already bold"; + let out = apply_agent_prefix(PrefixStyle::BoldBracket, "coder", already); + assert_eq!(out, already); + // Bracket style also detects the bolded form and leaves it alone. + let out2 = apply_agent_prefix(PrefixStyle::Bracket, "coder", already); + assert_eq!(out2, already); + } + + #[test] + fn test_apply_agent_prefix_empty_name_is_noop() { + let text = "no author"; + let out = apply_agent_prefix(PrefixStyle::Bracket, "", text); + assert_eq!(out, text); + } + + #[tokio::test] + async fn test_maybe_prefix_response_off_is_byte_identical() { + let agent_id = AgentId::new(); + let handle: Arc = Arc::new(MockHandle { + agents: Mutex::new(vec![(agent_id, "coder".to_string())]), + }); + let overrides = ChannelOverrides::default(); + let input = "Hello from the agent.".to_string(); + let original_bytes = input.clone(); + let out = maybe_prefix_response(&handle, Some(&overrides), agent_id, input).await; + assert_eq!(out.as_bytes(), original_bytes.as_bytes()); + } + + #[tokio::test] + async fn test_maybe_prefix_response_bracket_wraps() { + let agent_id = AgentId::new(); + let handle: Arc = Arc::new(MockHandle { + agents: Mutex::new(vec![(agent_id, "coder".to_string())]), + }); + let overrides = ChannelOverrides { + prefix_agent_name: PrefixStyle::Bracket, + ..Default::default() + }; + let out = + maybe_prefix_response(&handle, Some(&overrides), agent_id, "Hi".to_string()).await; + assert_eq!(out, "[coder] Hi"); + } + + #[tokio::test] + async fn test_maybe_prefix_response_bold_bracket_wraps() { + let agent_id = AgentId::new(); + let handle: Arc = Arc::new(MockHandle { + agents: Mutex::new(vec![(agent_id, "coder".to_string())]), + }); + let overrides = ChannelOverrides { + prefix_agent_name: PrefixStyle::BoldBracket, + ..Default::default() + }; + let out = + maybe_prefix_response(&handle, Some(&overrides), agent_id, "Hi".to_string()).await; + assert_eq!(out, "**[coder]** Hi"); + } + + #[tokio::test] + async fn test_maybe_prefix_response_unknown_agent_falls_back() { + // When the agent id isn't in list_agents, we leave the text alone + // rather than fabricating a label. + let known = AgentId::new(); + let unknown = AgentId::new(); + let handle: Arc = Arc::new(MockHandle { + agents: Mutex::new(vec![(known, "coder".to_string())]), + }); + let overrides = ChannelOverrides { + prefix_agent_name: PrefixStyle::Bracket, + ..Default::default() + }; + let out = maybe_prefix_response(&handle, Some(&overrides), unknown, "Hi".to_string()).await; + assert_eq!(out, "Hi"); + } + + #[test] + fn test_prefix_style_default_is_off_and_serde_snake_case() { + assert_eq!(PrefixStyle::default(), PrefixStyle::Off); + // Round-trip: the serialized representation is snake_case and + // an unspecified config field deserializes to Off so existing TOML + // keeps working. + let v: PrefixStyle = serde_json::from_str("\"bracket\"").unwrap(); + assert_eq!(v, PrefixStyle::Bracket); + let v: PrefixStyle = serde_json::from_str("\"bold_bracket\"").unwrap(); + assert_eq!(v, PrefixStyle::BoldBracket); + let v: PrefixStyle = serde_json::from_str("\"off\"").unwrap(); + assert_eq!(v, PrefixStyle::Off); + } + + #[test] + fn test_channel_overrides_default_prefix_off() { + let o = ChannelOverrides::default(); + assert_eq!(o.prefix_agent_name, PrefixStyle::Off); + } + #[test] fn test_media_type_from_url() { assert_eq!( diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 96424ff0..35f390f5 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -52,6 +52,30 @@ pub enum GroupPolicy { Ignore, } +/// Prefix style applied to outbound agent messages on a channel. +/// +/// When enabled, the channel bridge wraps the responding agent's reply with its +/// name so end-users can tell which agent authored the message when multiple +/// agents share the same channel. Default is `Off` to preserve existing +/// behavior. +/// +/// Platform-native identity (e.g. Slack per-message bot username override, +/// Discord embed author field) is intentionally out of scope here and will be +/// addressed in a follow-up. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrefixStyle { + /// No prefix — byte-identical to pre-feature behavior. + #[default] + Off, + /// Plain bracketed name: `[agent-name] text`. + Bracket, + /// Bold bracketed name via markdown: `**[agent-name]** text`. + /// Renders bold on platforms that support markdown (Discord, Telegram + /// markdown mode, Slack mrkdwn treats it as bold too). + BoldBracket, +} + /// Output format hint for channel-specific message formatting. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -93,6 +117,12 @@ pub struct ChannelOverrides { /// Defaults to true. Set to false to suppress automatic reactions (e.g. on Telegram). #[serde(default = "default_true")] pub lifecycle_reactions: bool, + /// Prefix outbound messages with the responding agent's name. + /// + /// Defaults to `PrefixStyle::Off` so enabling this feature is opt-in per + /// channel and existing configs keep their current output byte-for-byte. + #[serde(default)] + pub prefix_agent_name: PrefixStyle, } impl Default for ChannelOverrides { @@ -108,6 +138,7 @@ impl Default for ChannelOverrides { usage_footer: None, typing_mode: None, lifecycle_reactions: true, + prefix_agent_name: PrefixStyle::Off, } } }