Skip to main content

agent_engine/skills/
tool.rs

1//! `load_skill` tool — model-initiated skill activation.
2
3use crate::skills::{
4    registry::{CommandRegistry, Resolution},
5    LoadedSkill,
6};
7use serde_json::json;
8use std::sync::Arc;
9
10pub struct LoadSkillTool {
11    registry: Arc<CommandRegistry>,
12}
13
14impl LoadSkillTool {
15    pub fn new(registry: Arc<CommandRegistry>) -> Self {
16        Self { registry }
17    }
18
19    /// Produce the tool-result body for a successfully loaded skill.
20    /// Shared between user-initiated (slash) and model-initiated (tool) paths.
21    pub fn format_body(skill: &LoadedSkill) -> String {
22        format!(
23            "# Skill: {} — {}\n\nFollow these guidelines for the rest of this conversation.\n\n{}",
24            skill.name, skill.description, skill.body
25        )
26    }
27}
28
29#[async_trait::async_trait]
30impl crate::Tool for LoadSkillTool {
31    fn name(&self) -> &str {
32        "load_skill"
33    }
34
35    fn description(&self) -> &str {
36        "Load a skill to guide your behavior for the current conversation. \
37         Skills provide structured guidelines, checklists, and best practices. \
38         Call this when a task would benefit from a specific methodology."
39    }
40
41    fn parameters(&self) -> serde_json::Value {
42        let list: Vec<String> = self
43            .registry
44            .all_skills()
45            .iter()
46            .map(|s| {
47                let qualified = match &s.plugin {
48                    Some(p) => format!("{}:{} — {}", p, s.name, s.description),
49                    None => format!("{} — {}", s.name, s.description),
50                };
51                qualified
52            })
53            .collect();
54        json!({
55            "type": "object",
56            "properties": {
57                "skill": {
58                    "type": "string",
59                    "description": format!("Name of the skill to load (bare or plugin:skill). Available:\n{}", list.join("\n"))
60                }
61            },
62            "required": ["skill"]
63        })
64    }
65
66    async fn execute(
67        &self,
68        params: serde_json::Value,
69        _ctx: crate::ToolContext,
70    ) -> crate::Result<String> {
71        let name = params["skill"]
72            .as_str()
73            .ok_or_else(|| crate::RuntimeError::Tool("Missing 'skill' parameter".to_string()))?;
74
75        match self.registry.resolve(name) {
76            Resolution::Skill(s) => Ok(Self::format_body(&s)),
77            Resolution::Ambiguous(opts) => Err(crate::RuntimeError::Tool(format!(
78                "ambiguous skill '{}'; specify one of: {}",
79                name,
80                opts.join(", ")
81            ))),
82            Resolution::PluginCommand(_) | Resolution::Builtin | Resolution::Unknown => Err(
83                crate::RuntimeError::Tool(format!("unknown skill '{}'", name)),
84            ),
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::path::PathBuf;
93
94    fn test_ctx() -> crate::ToolContext {
95        crate::ToolContext {
96            channels: crate::tools::ToolChannels {
97                tx_delta: None,
98                tx_events: None,
99            },
100            capabilities: crate::tools::ToolCapabilities {
101                watcher_exit_path: None,
102                tool_register_tx: None,
103                session_manager: None,
104                subagent_registry: None,
105                event_queue: None,
106                secret_prompt: None,
107            },
108            limits: crate::tools::ToolLimits {
109                max_tool_output: 30000,
110                max_tool_buffer: 256 * 1024,
111                bash_timeout: 30,
112                bash_max_timeout: 300,
113                subagent_timeout: 300,
114            },
115        }
116    }
117
118    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
119        LoadedSkill {
120            name: name.to_string(),
121            description: format!("desc-{name}"),
122            body: format!("body-{name}"),
123            plugin: plugin.map(str::to_string),
124            base_dir: PathBuf::from("/"),
125            source_path: PathBuf::from("/SKILL.md"),
126        }
127    }
128
129    #[test]
130    fn format_body_includes_name_and_description() {
131        let s = LoadedSkill {
132            name: "x".into(),
133            description: "y".into(),
134            body: "z".into(),
135            plugin: None,
136            base_dir: PathBuf::from("/"),
137            source_path: PathBuf::from("/SKILL.md"),
138        };
139        let out = LoadSkillTool::format_body(&s);
140        assert!(out.contains("x"));
141        assert!(out.contains("y"));
142        assert!(out.contains("z"));
143        assert!(out.contains("Follow these guidelines"));
144    }
145
146    #[tokio::test]
147    async fn execute_returns_skill_body_on_unique_match() {
148        use crate::Tool;
149        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
150            &[],
151            vec![mk("search", Some("p1"))],
152        ));
153        let tool = LoadSkillTool::new(reg);
154        let result = tool
155            .execute(serde_json::json!({"skill": "search"}), test_ctx())
156            .await
157            .unwrap();
158        assert!(result.contains("# Skill: search"));
159        assert!(result.contains("desc-search"));
160        assert!(result.contains("body-search"));
161    }
162
163    #[tokio::test]
164    async fn execute_errors_on_ambiguous() {
165        use crate::Tool;
166        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
167            &[],
168            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
169        ));
170        let tool = LoadSkillTool::new(reg);
171        let err = tool
172            .execute(serde_json::json!({"skill": "search"}), test_ctx())
173            .await
174            .unwrap_err();
175        let msg = format!("{err}");
176        assert!(msg.contains("ambiguous"));
177        assert!(msg.contains("p1:search"));
178        assert!(msg.contains("p2:search"));
179    }
180
181    #[tokio::test]
182    async fn execute_errors_on_unknown() {
183        use crate::Tool;
184        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
185        let tool = LoadSkillTool::new(reg);
186        let err = tool
187            .execute(serde_json::json!({"skill": "nosuch"}), test_ctx())
188            .await
189            .unwrap_err();
190        assert!(format!("{err}").contains("unknown skill 'nosuch'"));
191    }
192
193    #[tokio::test]
194    async fn execute_errors_on_builtin() {
195        use crate::Tool;
196        // A built-in is not a skill; load_skill should refuse to load it.
197        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(
198            &["clear"],
199            vec![],
200        ));
201        let tool = LoadSkillTool::new(reg);
202        let err = tool
203            .execute(serde_json::json!({"skill": "clear"}), test_ctx())
204            .await
205            .unwrap_err();
206        assert!(format!("{err}").contains("unknown skill 'clear'"));
207    }
208
209    #[tokio::test]
210    async fn execute_errors_on_missing_skill_param() {
211        use crate::Tool;
212        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
213        let tool = LoadSkillTool::new(reg);
214        let err = tool
215            .execute(serde_json::json!({}), test_ctx())
216            .await
217            .unwrap_err();
218        assert!(format!("{err}").contains("Missing 'skill' parameter"));
219    }
220
221    #[test]
222    fn parameters_schema_is_well_formed() {
223        use crate::Tool;
224        let reg = Arc::new(crate::skills::registry::CommandRegistry::new(&[], vec![]));
225        let tool = LoadSkillTool::new(reg);
226        let schema = tool.parameters();
227        assert_eq!(schema["type"], "object");
228        assert_eq!(schema["properties"]["skill"]["type"], "string");
229        assert_eq!(schema["required"], serde_json::json!(["skill"]));
230    }
231}