feat(channels): optional agent name prefix on outbound messages (#980) (#1072)

Adds an opt-in per-channel knob prefix_agent_name on ChannelOverrides
with styles Off (default), Bracket ([agent] text) and BoldBracket
(**[agent]** text).

The bridge wraps the final outbound text once in dispatch_message,
dispatch_with_blocks and the auto-reply path. Off is byte-identical
to pre-feature behavior so existing configs are unaffected.

Platform-native identity overrides (Slack per-message username,
Discord embed author field) are intentionally out of scope here and
tracked as a follow-up.

Made-with: Cursor
This commit is contained in:
Jaber Jaber
2026-04-17 22:47:04 +03:00
committed by GitHub
parent 07af248a07
commit 00c0ff60de
2 changed files with 258 additions and 1 deletions

View File

@@ -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<dyn ChannelBridgeHandle>, id: AgentId) -> Option<String> {
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<dyn ChannelBridgeHandle>,
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<dyn ChannelBridgeHandle> = 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<dyn ChannelBridgeHandle> = 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<dyn ChannelBridgeHandle> = 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<dyn ChannelBridgeHandle> = 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!(

View File

@@ -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,
}
}
}