procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use async_trait::async_trait;
use serde_json::{json, Value};

use std::sync::Arc;

use super::Tool;
use crate::personas::PersonaRegistry;

/// Returns the shared persona registry, discovered from disk on first use.
async fn load_registry() -> Arc<PersonaRegistry> {
    crate::registries::personas().await
}

/// Tool: talk_to
///
/// Activates a persona and runs a sub-agent with that persona's system prompt.
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() {
            // Told rather than left to `run_skill`: a specialist that has to remember to load its
            // own reference material will answer from memory on the first turn instead.
            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();

        // The tools this specialist actually gets — the session's registry cut down to its own
        // list and its risk ceiling, not a fresh empty one. A fresh registry was the previous
        // behaviour, and it made every persona identical: a system prompt with a name on it and
        // nothing it could do beyond talk.
        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, // default per-request deadline
        };

        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
        ))
    }
}

/// Tool: list_personas
///
/// Lists all available personas with their names, titles, and icons.
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 {
            output.push_str(&format!(
                "  {} {}{} ({})\n",
                p.icon, p.name, p.title, p.skill_name
            ));
            if !p.when_to_use.is_empty() {
                output.push_str(&format!(
                    "    Use when: {}\n",
                    truncate(&p.when_to_use, 140)
                ));
            } else if !p.role.is_empty() {
                output.push_str(&format!("    {}\n", truncate(&p.role, 100)));
            }
            // The operational difference, not just the voice: what it may touch and how far.
            let tool_count = p
                .tools
                .as_ref()
                .map(|t| t.len().to_string())
                .unwrap_or_else(|| "all".to_string());
            output.push_str(&format!(
                "    Tools: {}  ·  Ceiling: {:?}\n",
                tool_count, p.ceiling
            ));
            output.push('\n');
        }

        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...");
    }
}