use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentProfile {
pub name: String,
#[serde(default = "default_personality")]
pub personality: String,
pub system_prompt: Option<String>,
pub model: Option<String>,
}
impl AgentProfile {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
personality: default_personality(),
system_prompt: None,
model: None,
}
}
pub fn with_personality(mut self, personality: impl Into<String>) -> Self {
self.personality = personality.into();
self
}
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
}
fn default_personality() -> String {
"friendly, helpful, and concise".into()
}
pub fn default_agent_name() -> String {
"Atlas".into()
}
pub fn resolve_agents(
agents: &[AgentProfile],
legacy_name: Option<&str>,
legacy_personality: Option<&str>,
legacy_system_prompt: Option<&str>,
) -> Vec<AgentProfile> {
if !agents.is_empty() {
return agents.to_vec();
}
vec![AgentProfile {
name: legacy_name
.unwrap_or("Atlas")
.to_string(),
personality: legacy_personality
.unwrap_or("friendly, helpful, and concise")
.to_string(),
system_prompt: legacy_system_prompt.map(|s| s.to_string()),
model: None,
}]
}
pub fn detect_agent_mention<'a>(content: &str, agent_names: &'a [String]) -> (Option<&'a str>, String) {
let content_lower = content.to_lowercase();
for name in agent_names {
let pattern = format!("@{}", name.to_lowercase());
if let Some(pos) = content_lower.find(&pattern) {
let end = pos + pattern.len();
let at_end = end >= content.len();
let next_is_boundary = at_end
|| !content.as_bytes()[end].is_ascii_alphanumeric();
if next_is_boundary {
let mut cleaned = String::with_capacity(content.len());
cleaned.push_str(&content[..pos]);
cleaned.push_str(&content[end..]);
return (Some(name.as_str()), cleaned);
}
}
}
(None, content.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_profile_builder() {
let profile = AgentProfile::new("CodeBot")
.with_personality("a precise coding assistant")
.with_model("claude-sonnet-4-20250514");
assert_eq!(profile.name, "CodeBot");
assert_eq!(profile.personality, "a precise coding assistant");
assert!(profile.system_prompt.is_none());
assert_eq!(profile.model.as_deref(), Some("claude-sonnet-4-20250514"));
}
#[test]
fn agent_profile_with_system_prompt() {
let profile = AgentProfile::new("Helper")
.with_system_prompt("You are a pirate.");
assert_eq!(profile.system_prompt.as_deref(), Some("You are a pirate."));
}
#[test]
fn agent_profile_defaults() {
let profile = AgentProfile::new("Test");
assert_eq!(profile.personality, "friendly, helpful, and concise");
assert!(profile.system_prompt.is_none());
assert!(profile.model.is_none());
}
#[test]
fn resolve_agents_uses_explicit_list() {
let agents = vec![
AgentProfile::new("Atlas"),
AgentProfile::new("CodeBot"),
];
let resolved = resolve_agents(&agents, Some("OldName"), None, None);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "Atlas");
assert_eq!(resolved[1].name, "CodeBot");
}
#[test]
fn resolve_agents_falls_back_to_legacy() {
let resolved = resolve_agents(
&[],
Some("MyBot"),
Some("snarky"),
Some("You are snarky."),
);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "MyBot");
assert_eq!(resolved[0].personality, "snarky");
assert_eq!(resolved[0].system_prompt.as_deref(), Some("You are snarky."));
}
#[test]
fn resolve_agents_uses_defaults_when_no_legacy() {
let resolved = resolve_agents(&[], None, None, None);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "Atlas");
assert_eq!(resolved[0].personality, "friendly, helpful, and concise");
}
#[test]
fn agent_profile_serialization_roundtrip() {
let profile = AgentProfile::new("Atlas")
.with_personality("friendly")
.with_model("claude-opus-4-6");
let json = serde_json::to_string(&profile).unwrap();
let deserialized: AgentProfile = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Atlas");
assert_eq!(deserialized.personality, "friendly");
assert_eq!(deserialized.model.as_deref(), Some("claude-opus-4-6"));
}
#[test]
fn mention_finds_match() {
let agents = vec!["Atlas".into(), "CodeBot".into()];
let (agent, content) = detect_agent_mention("@Atlas what is 2+2?", &agents);
assert_eq!(agent, Some("Atlas"));
assert_eq!(content, " what is 2+2?");
}
#[test]
fn mention_case_insensitive() {
let agents = vec!["Atlas".into(), "CodeBot".into()];
let (agent, content) = detect_agent_mention("@codebot help me", &agents);
assert_eq!(agent, Some("CodeBot"));
assert_eq!(content, " help me");
}
#[test]
fn mention_no_match() {
let agents = vec!["Atlas".into()];
let (agent, content) = detect_agent_mention("hello world", &agents);
assert!(agent.is_none());
assert_eq!(content, "hello world");
}
#[test]
fn mention_empty_agents() {
let agents: Vec<String> = vec![];
let (agent, content) = detect_agent_mention("@Atlas hello", &agents);
assert!(agent.is_none());
assert_eq!(content, "@Atlas hello");
}
#[test]
fn mention_word_boundary() {
let agents = vec!["Atlas".into()];
let (agent, _) = detect_agent_mention("@Atlas123 hello", &agents);
assert!(agent.is_none());
let (agent, content) = detect_agent_mention("@Atlas hello", &agents);
assert_eq!(agent, Some("Atlas"));
assert_eq!(content, " hello");
}
#[test]
fn mention_mid_sentence() {
let agents = vec!["Atlas".into(), "CodeBot".into()];
let (agent, content) = detect_agent_mention("Hey @CodeBot, explain this", &agents);
assert_eq!(agent, Some("CodeBot"));
assert_eq!(content, "Hey , explain this");
}
#[test]
fn mention_at_end() {
let agents = vec!["Atlas".into()];
let (agent, content) = detect_agent_mention("hello @Atlas", &agents);
assert_eq!(agent, Some("Atlas"));
assert_eq!(content, "hello ");
}
}