pub mod builtin;
use crate::risk::Capability;
use crate::skills::{loader::Skill, SkillRegistry};
#[derive(Debug, Clone)]
pub struct Persona {
pub skill_name: String,
pub name: String,
pub title: String,
pub icon: String,
pub role: String,
pub identity: String,
pub communication_style: String,
pub principles: Vec<String>,
pub body: String,
pub when_to_use: String,
pub tools: Option<Vec<String>>,
pub skills: Vec<String>,
pub ceiling: Capability,
}
impl Persona {
pub fn system_prompt(&self, workspace_context: &str) -> String {
let mut prompt = format!(
"# {} — {}\n\n{}\n\n## Role\n\n{}\n\n## Identity\n\n{}\n\n## Communication Style\n\n{}\n\n## Principles\n\n",
self.name, self.title, self.body, self.role, self.identity, self.communication_style
);
for p in &self.principles {
prompt.push_str(&format!("- {}\n", p));
}
if !self.when_to_use.is_empty() {
prompt.push_str(&format!(
"\n## When you are called\n\n{}\n",
self.when_to_use
));
}
let limit = match self.ceiling {
Capability::ReadOnly | Capability::Build | Capability::Delegating => Some(
"You cannot change files or sign anything. If the work needs that, say what has \
to be done and hand it back rather than looking for another way.",
),
Capability::Write => Some(
"You may change files. You cannot deploy, invoke or sign — hand that back when it \
is needed.",
),
Capability::Signing => None,
};
if let Some(limit) = limit {
prompt.push_str(&format!("\n## Your limits\n\n{}\n", limit));
}
if !workspace_context.is_empty() {
prompt.push_str(&format!(
"\n## Workspace Context\n\n{}\n",
workspace_context
));
}
prompt
}
}
pub struct PersonaRegistry {
personas: Vec<Persona>,
}
impl PersonaRegistry {
pub fn new() -> Self {
Self {
personas: Vec::new(),
}
}
pub fn discover(skill_registry: &SkillRegistry) -> (Self, Vec<String>) {
let mut personas = builtin::personas();
let mut warnings = Vec::new();
for skill in skill_registry.all() {
if !skill.has_customize {
continue;
}
match Self::load_persona(skill) {
Ok(persona) => {
if let Some(shadowed) = personas
.iter()
.find(|p| p.skill_name == persona.skill_name || p.name == persona.name)
{
warnings.push(format!(
"{} defines a persona named '{}', which is built in; the built-in one \
is being used. Rename it to load yours.",
skill.path.display(),
shadowed.name
));
continue;
}
personas.push(persona);
}
Err(e) => {
warnings.push(format!("Failed to load persona from {}: {}", skill.name, e));
}
}
}
(Self { personas }, warnings)
}
fn load_persona(skill: &Skill) -> Result<Persona, String> {
let customize_path = skill.path.join("customize.toml");
let content = match std::fs::read_to_string(&customize_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err("No customize.toml found".to_string());
}
Err(e) => {
return Err(format!("Failed to read customize.toml: {}", e));
}
};
let config: toml::Value = toml::from_str(&content)
.map_err(|e| format!("Failed to parse customize.toml: {}", e))?;
let agent = config
.get("agent")
.ok_or_else(|| "No [agent] section in customize.toml".to_string())?;
Ok(Persona {
skill_name: skill.name.clone(),
name: agent
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&skill.name)
.to_string(),
title: agent
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
icon: agent
.get("icon")
.and_then(|v| v.as_str())
.unwrap_or("🤖")
.to_string(),
role: agent
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
identity: agent
.get("identity")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
communication_style: agent
.get("communication_style")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
principles: agent
.get("principles")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
body: skill.body.clone(),
when_to_use: agent
.get("when_to_use")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
tools: agent.get("tools").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
}),
skills: agent
.get("skills")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
ceiling: match agent.get("risk").and_then(|v| v.as_str()) {
Some("signing") => Capability::Signing,
Some("write") => Capability::Write,
_ => Capability::ReadOnly,
},
})
}
pub fn all(&self) -> &[Persona] {
&self.personas
}
pub fn get(&self, name: &str) -> Option<&Persona> {
self.personas
.iter()
.find(|p| p.skill_name == name || p.name.to_lowercase() == name.to_lowercase())
}
pub fn search(&self, query: &str) -> Vec<&Persona> {
let query_lower = query.to_lowercase();
self.personas
.iter()
.filter(|p| {
p.skill_name.to_lowercase().contains(&query_lower)
|| p.name.to_lowercase().contains(&query_lower)
|| p.title.to_lowercase().contains(&query_lower)
})
.collect()
}
}
impl Default for PersonaRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_persona(name: &str, title: &str) -> Persona {
Persona {
skill_name: format!("{}-test", name.to_lowercase()),
name: name.to_string(),
title: title.to_string(),
icon: "🤖".to_string(),
role: format!("{} role", name),
identity: format!("{} identity", name),
communication_style: "Professional".to_string(),
principles: vec!["Be helpful".to_string()],
body: format!("# {} — {}\n\nBody content.", name, title),
when_to_use: format!("When you need {}", title.to_lowercase()),
tools: None,
skills: Vec::new(),
ceiling: Capability::ReadOnly,
}
}
#[test]
fn get_finds_by_skill_name() {
let mut reg = PersonaRegistry::new();
reg.personas.push(make_persona("Tyler", "Architect"));
assert!(reg.get("tyler-test").is_some());
assert!(reg.get("Tyler").is_some());
}
#[test]
fn search_matches_name_and_title() {
let mut reg = PersonaRegistry::new();
reg.personas.push(make_persona("Tyler", "Architect"));
reg.personas.push(make_persona("Nicole", "PM"));
let results = reg.search("architect");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Tyler");
}
#[test]
fn search_is_case_insensitive() {
let mut reg = PersonaRegistry::new();
reg.personas.push(make_persona("Elliot", "Developer"));
assert_eq!(reg.search("ELLIOT").len(), 1);
assert_eq!(reg.search("developer").len(), 1);
}
#[test]
fn system_prompt_includes_all_persona_fields() {
let persona = make_persona("Tyler", "Architect");
let prompt = persona.system_prompt("Workspace: /project");
assert!(prompt.contains("Tyler"));
assert!(prompt.contains("Architect"));
assert!(prompt.contains("Tyler role"));
assert!(prompt.contains("Tyler identity"));
assert!(prompt.contains("Professional"));
assert!(prompt.contains("Be helpful"));
assert!(prompt.contains("Workspace: /project"));
}
#[test]
fn system_prompt_states_when_it_applies() {
let mut persona = make_persona("Tyler", "Architect");
persona.when_to_use = "Deciding contract layout.".to_string();
assert!(persona
.system_prompt("")
.contains("Deciding contract layout."));
}
#[test]
fn a_read_only_persona_is_told_it_cannot_change_anything() {
let mut persona = make_persona("Tyler", "Architect");
persona.ceiling = Capability::ReadOnly;
assert!(persona
.system_prompt("")
.contains("cannot change files or sign"));
}
#[test]
fn a_write_persona_is_told_it_still_cannot_sign() {
let mut persona = make_persona("Nicole", "PM");
persona.ceiling = Capability::Write;
let prompt = persona.system_prompt("");
assert!(prompt.contains("cannot deploy, invoke or sign"));
}
#[test]
fn a_signing_persona_carries_no_limits_disclaimer() {
let mut persona = make_persona("Tyler", "Architect");
persona.ceiling = Capability::Signing;
assert!(!persona.system_prompt("").contains("Your limits"));
}
#[test]
fn a_disk_persona_cannot_shadow_a_built_in_one() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("security-auditor");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: security-auditor\ndescription: fake\n---\n\nBody.",
)
.unwrap();
std::fs::write(
skill_dir.join("customize.toml"),
"[agent]\nname = \"SecurityAuditor\"\nrisk = \"signing\"\n",
)
.unwrap();
let (skills, _) = crate::skills::loader::SkillLoader::discover(&[tmp.path().to_path_buf()]);
let skills = SkillRegistry::from_skills_for_test(skills);
let (personas, warnings) = PersonaRegistry::discover(&skills);
let auditor = personas.get("security-auditor").unwrap();
assert_eq!(
auditor.ceiling,
Capability::ReadOnly,
"the built-in ceiling must survive a disk file claiming a wider one"
);
assert!(
warnings.iter().any(|w| w.contains("built in")),
"the shadowing attempt should be reported: {:?}",
warnings
);
}
#[test]
fn system_prompt_with_empty_context() {
let persona = make_persona("Nicole", "PM");
let prompt = persona.system_prompt("");
assert!(prompt.contains("Nicole"));
assert!(!prompt.contains("Workspace Context"));
}
}