use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use super::Tool;
use crate::personas::PersonaRegistry;
async fn load_registry() -> Arc<PersonaRegistry> {
crate::registries::personas().await
}
pub struct TalkToTool;
#[async_trait]
impl Tool for TalkToTool {
fn name(&self) -> &str {
"talk_to"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Delegating
}
fn description(&self) -> &str {
"Talk to a specific persona (Justin, Bri, Nicole, Kaan, Tyler, Elliot). \
Spawns a sub-agent with the persona's identity and runs it to completion. \
Use when the user asks to talk to someone by name."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"persona": {
"type": "string",
"description": "Persona name or skill name (e.g. 'tyler-architect', 'Tyler', 'Elliot')"
},
"message": {
"type": "string",
"description": "The message or task for the persona"
},
"model": {
"type": "string",
"description": "Optional model override for the sub-agent"
}
},
"required": ["persona", "message"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let persona_name = input
.get("persona")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'persona' field".to_string())?;
let message = input
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'message' field".to_string())?
.to_string();
let model = input
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let registry = load_registry().await;
let persona = registry.get(persona_name).ok_or_else(|| {
let suggestions: Vec<_> = registry
.search(persona_name)
.iter()
.map(|p| format!("{} ({})", p.name, p.skill_name))
.collect();
if suggestions.is_empty() {
format!(
"Persona '{}' not found. Use list_personas to see available personas.",
persona_name
)
} else {
format!(
"Persona '{}' not found. Did you mean: {}?",
persona_name,
suggestions.join(", ")
)
}
})?;
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut workspace_context = format!("Workspace: {}", cwd.display());
if !persona.skills.is_empty() {
workspace_context.push_str(&format!(
"\nLoad these skills before answering: {}",
persona.skills.join(", ")
));
}
let system_prompt = persona.system_prompt(&workspace_context);
let config = crate::config::AppConfig::load().unwrap_or_default();
let loaded = crate::runtime::persona_tools(&config, persona).await;
let subagent_config = crate::agent::subagent::SubAgentConfig {
system_prompt,
message,
model,
max_tokens: None,
max_rounds: None,
allowed_tools: None,
timeout_secs: None, };
let response =
crate::agent::subagent::run_subagent(&config, subagent_config, &loaded.registry)
.await
.map_err(|e| format!("{} failed: {}", persona.name, e))?;
Ok(format!(
"{} **{}:**\n\n{}",
persona.icon, persona.name, response.text
))
}
}
pub struct ListPersonasTool;
#[async_trait]
impl Tool for ListPersonasTool {
fn name(&self) -> &str {
"list_personas"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"List all available personas (Justin, Bri, Nicole, Kaan, Tyler, Elliot). \
Shows name, title, icon, and skill name."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"search": {
"type": "string",
"description": "Optional search query to filter personas"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let search = input.get("search").and_then(|v| v.as_str()).unwrap_or("");
let registry = load_registry().await;
let personas = if search.is_empty() {
registry.all().to_vec()
} else {
registry.search(search).into_iter().cloned().collect()
};
if personas.is_empty() {
if search.is_empty() {
return Ok("No personas found.\n\n\
Personas are skills with a customize.toml containing an [agent] section.\n\
They are loaded from:\n\
- ~/.claude/skills/<name>/ (global)\n\
- .stellar-build/skills/<name>/ (project)\n\
- .procyon/skills/<name>/ (project)"
.to_string());
}
return Ok(format!("No personas match '{}'.", search));
}
let mut output = format!("Available personas ({}):\n\n", personas.len());
for p in &personas {
let blurb = if !p.role.is_empty() {
&p.role
} else {
&p.when_to_use
};
output.push_str(&format!(
" {} {} ({}) — {}\n",
p.icon,
p.name,
p.skill_name,
truncate(blurb, 100)
));
}
Ok(output)
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRegistry;
#[tokio::test]
async fn list_personas_returns_message_when_empty() {
let tool = ListPersonasTool;
let result = tool.execute(json!({})).await.unwrap();
assert!(
result.contains("No personas") || result.contains("Available personas"),
"got: {}",
result
);
}
#[tokio::test]
async fn talk_to_returns_error_for_unknown() {
let tool = TalkToTool;
let err = tool
.execute(json!({"persona": "nonexistent", "message": "hi"}))
.await
.unwrap_err();
assert!(err.contains("not found"), "got: {}", err);
}
#[tokio::test]
async fn tools_are_callable() {
let talk = TalkToTool;
let list = ListPersonasTool;
assert_eq!(talk.name(), "talk_to");
assert_eq!(list.name(), "list_personas");
assert!(!talk.description().is_empty());
assert!(!list.description().is_empty());
}
#[tokio::test]
async fn tools_register_in_registry() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(TalkToTool));
registry.register(Box::new(ListPersonasTool));
assert!(registry.get_tool("talk_to").is_some());
assert!(registry.get_tool("list_personas").is_some());
}
#[test]
fn truncate_works() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("this is a long description", 10), "this is...");
}
}