pub const DEFAULT_MAX_MENTION_DEPTH: u32 = 3;
#[derive(Debug, thiserror::Error)]
pub enum MentionChainError {
#[error("agent '{agent}' cannot mention itself")]
SelfMention { agent: String },
#[error("mention chain depth {depth} exceeds max {max_depth} for agent '{agent}'")]
DepthExceeded {
depth: u32,
max_depth: u32,
agent: String,
},
#[error("cycle detected: {from} -> {to} would create a loop")]
CycleDetected { from: String, to: String },
}
pub struct MentionChainTracker;
impl MentionChainTracker {
pub fn check(
depth: u32,
parent_agent: &str,
target_agent: &str,
max_depth: u32,
) -> Result<(), MentionChainError> {
if parent_agent == target_agent && !parent_agent.is_empty() {
return Err(MentionChainError::SelfMention {
agent: target_agent.to_string(),
});
}
if depth >= max_depth {
return Err(MentionChainError::DepthExceeded {
depth,
max_depth,
agent: target_agent.to_string(),
});
}
Ok(())
}
pub fn build_context(args: &MentionContextArgs, max_depth: u32) -> String {
let remaining = max_depth.saturating_sub(args.depth + 1);
let parent_line = if args.parent_agent.is_empty() {
"Triggered by: human mention".to_string()
} else {
format!(
"Triggered by: `@adf:{}` on issue #{}",
args.parent_agent, args.issue_number
)
};
let body_excerpt = if args.comment_body.len() > 2000 {
let mut end = 2000;
while !args.comment_body.is_char_boundary(end) {
end -= 1;
}
format!("{}\n...[truncated]", &args.comment_body[..end])
} else {
args.comment_body.clone()
};
let agents_section = if args.available_agents.is_empty() {
String::new()
} else {
let list: String = args
.available_agents
.iter()
.map(|a| format!("- `@adf:{}`", a))
.collect::<Vec<_>>()
.join("\n");
format!("\nAvailable agents to mention:\n{}\n", list)
};
format!(
"---\n\
**Mention Context** (chain: `{}`, depth: {})\n\
{}\n\
---\n\
\n{}\n\
\n---\n\
When your work is complete, you may mention another agent using \
`@adf:agent-name` in your output.{}\n\
Maximum mention chain depth remaining: {}\n\
---",
args.chain_id, args.depth, parent_line, body_excerpt, agents_section, remaining
)
}
}
#[derive(Debug, Clone, Default)]
pub struct MentionContextArgs {
pub parent_agent: String,
pub issue_number: u64,
pub comment_body: String,
pub depth: u32,
pub chain_id: String,
pub available_agents: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_self_mention_rejected() {
let result = MentionChainTracker::check(0, "agent-a", "agent-a", 3);
assert!(matches!(result, Err(MentionChainError::SelfMention { .. })));
}
#[test]
fn test_depth_limit_enforced() {
let result = MentionChainTracker::check(3, "agent-a", "agent-b", 3);
assert!(matches!(
result,
Err(MentionChainError::DepthExceeded { depth: 3, .. })
));
}
#[test]
fn test_depth_zero_allowed() {
let result = MentionChainTracker::check(0, "", "agent-a", 3);
assert!(result.is_ok());
}
#[test]
fn test_depth_one_allowed() {
let result = MentionChainTracker::check(1, "agent-a", "agent-b", 3);
assert!(result.is_ok());
}
#[test]
fn test_depth_two_allowed() {
let result = MentionChainTracker::check(2, "agent-a", "agent-b", 3);
assert!(result.is_ok());
}
#[test]
fn test_depth_three_blocked() {
let result = MentionChainTracker::check(3, "agent-a", "agent-b", 3);
assert!(matches!(
result,
Err(MentionChainError::DepthExceeded { .. })
));
}
#[test]
fn test_cycle_detection_ab_a() {
let result = MentionChainTracker::check(1, "agent-b", "agent-a", 3);
assert!(result.is_ok());
}
#[test]
fn test_different_agents_allowed() {
let result = MentionChainTracker::check(2, "agent-a", "agent-c", 3);
assert!(result.is_ok());
}
#[test]
fn test_config_default_mention_depth() {
assert_eq!(DEFAULT_MAX_MENTION_DEPTH, 3);
}
#[test]
fn test_build_context_includes_chain_id() {
let args = MentionContextArgs {
parent_agent: "agent-a".to_string(),
issue_number: 42,
comment_body: "please review".to_string(),
depth: 1,
chain_id: "01HZTEST123".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("01HZTEST123"));
assert!(ctx.contains("depth: 1"));
assert!(ctx.contains("@adf:agent-a"));
assert!(ctx.contains("#42"));
}
#[test]
fn test_build_context_includes_remaining_depth() {
let args = MentionContextArgs {
parent_agent: "agent-a".to_string(),
issue_number: 1,
comment_body: "do thing".to_string(),
depth: 1,
chain_id: "chain-1".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("remaining: 1"));
}
#[test]
fn test_build_context_human_mention() {
let args = MentionContextArgs {
parent_agent: String::new(),
issue_number: 5,
comment_body: "please check".to_string(),
depth: 0,
chain_id: "chain-human".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("human mention"));
assert!(ctx.contains("remaining: 2"));
}
#[test]
fn test_build_context_truncates_long_body() {
let long_body = "x".repeat(3000);
let args = MentionContextArgs {
parent_agent: String::new(),
issue_number: 1,
comment_body: long_body,
depth: 0,
chain_id: "chain-1".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("[truncated]"));
}
#[test]
fn test_empty_parent_not_self_mention() {
let result = MentionChainTracker::check(0, "", "agent-a", 3);
assert!(result.is_ok());
}
#[test]
fn test_depth_zero_at_zero_max() {
let result = MentionChainTracker::check(0, "", "agent-a", 0);
assert!(matches!(
result,
Err(MentionChainError::DepthExceeded { .. })
));
}
#[test]
fn test_build_context_includes_available_agents() {
let args = MentionContextArgs {
parent_agent: String::new(),
issue_number: 1,
comment_body: "please review".to_string(),
depth: 0,
chain_id: "chain-1".to_string(),
available_agents: vec!["reviewer".to_string(), "coder".to_string()],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("Available agents to mention"));
assert!(ctx.contains("`@adf:reviewer`"));
assert!(ctx.contains("`@adf:coder`"));
}
#[test]
fn test_truncation_safe_on_multibyte_boundary() {
let mut body = String::new();
while body.len() < 2100 {
body.push('\u{1F600}');
}
let args = MentionContextArgs {
parent_agent: String::new(),
issue_number: 1,
comment_body: body,
depth: 0,
chain_id: "chain-1".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(ctx.contains("[truncated]"));
assert!(ctx.contains('\u{1F600}'));
}
#[test]
fn test_build_context_empty_agents_no_section() {
let args = MentionContextArgs {
parent_agent: String::new(),
issue_number: 1,
comment_body: "please review".to_string(),
depth: 0,
chain_id: "chain-1".to_string(),
available_agents: vec![],
};
let ctx = MentionChainTracker::build_context(&args, 3);
assert!(!ctx.contains("Available agents to mention"));
}
}