Skip to main content

matrixcode_core/tools/
skill.rs

1//! `skill` tool: loads a named skill's full instructions into the
2//! conversation on demand. Only skills that were discovered at startup
3//! are accessible. The tool response contains the skill body plus a
4//! listing of the skill's directory so the model knows what files it
5//! can `read` next.
6
7use std::sync::Arc;
8
9use anyhow::Result;
10use async_trait::async_trait;
11use serde_json::{Value, json};
12
13use super::{Tool, ToolDefinition};
14use crate::skills::{Skill, list_skill_files};
15
16pub struct SkillTool {
17    skills: Arc<Vec<Skill>>,
18}
19
20impl SkillTool {
21    pub fn new(skills: Arc<Vec<Skill>>) -> Self {
22        Self { skills }
23    }
24}
25
26#[async_trait]
27impl Tool for SkillTool {
28    fn definition(&self) -> ToolDefinition {
29        // Build an enum of valid names so the model gets a typed hint
30        // instead of a free-form string. Empty enum would be invalid
31        // JSON-Schema, so we only include it when at least one skill
32        // is loaded.
33        let mut props = json!({
34            "name": {
35                "type": "string",
36                "description": "Name of the skill to load (must match one listed in the system prompt)."
37            }
38        });
39        if !self.skills.is_empty() {
40            let names: Vec<Value> = self
41                .skills
42                .iter()
43                .map(|s| Value::String(s.name.clone()))
44                .collect();
45            props["name"]["enum"] = Value::Array(names);
46        }
47
48        ToolDefinition {
49            name: "skill".to_string(),
50            description:
51                "Load the full instructions for a named skill. Call this when a skill \
52                 listed in the system prompt looks relevant to the user's request. \
53                 The response includes the skill's body and a list of files in its \
54                 directory that you can read with the `read` tool."
55                    .to_string(),
56            parameters: json!({
57                "type": "object",
58                "properties": props,
59                "required": ["name"]
60            }),
61        }
62    }
63
64    async fn execute(&self, params: Value) -> Result<String> {
65        let name = params["name"]
66            .as_str()
67            .ok_or_else(|| anyhow::anyhow!("missing 'name'"))?;
68
69        // Show spinner while loading skill - RAII guard ensures cleanup on error
70        // let mut spinner = ToolSpinner::new(&format!("loading skill '{}'", name));
71
72        let skill = self
73            .skills
74            .iter()
75            .find(|s| s.name == name)
76            .ok_or_else(|| {
77                let available: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
78                // spinner.finish_error("skill not found");
79                anyhow::anyhow!(
80                    "unknown skill '{}'. Available: {}",
81                    name,
82                    if available.is_empty() {
83                        "(none loaded)".to_string()
84                    } else {
85                        available.join(", ")
86                    }
87                )
88            })?;
89
90        let files = list_skill_files(&skill.dir);
91        let files_section = if files.is_empty() {
92            String::new()
93        } else {
94            let mut s = String::from("\n\n---\nFiles in this skill (read with the `read` tool, paths are relative to the skill directory):\n");
95            s.push_str(&format!("skill_dir: {}\n", skill.dir.display()));
96            for f in files {
97                s.push_str(&format!("- {}\n", f));
98            }
99            s
100        };
101
102        let result = format!(
103            "# Skill: {}\n\n{}{}",
104            skill.name,
105            skill.body.trim_end(),
106            files_section
107        );
108
109        // spinner.finish_success(&format!("skill '{}' loaded", name));
110        Ok(result)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::path::PathBuf;
118
119    fn fake_skill(name: &str, body: &str) -> Skill {
120        Skill {
121            name: name.to_string(),
122            description: "desc".to_string(),
123            dir: PathBuf::from("/nonexistent"),
124            body: body.to_string(),
125            source_file: PathBuf::from("/nonexistent/SKILL.md"),
126        }
127    }
128
129    #[tokio::test]
130    async fn returns_skill_body() {
131        let skills = Arc::new(vec![fake_skill("demo", "do the thing")]);
132        let tool = SkillTool::new(skills);
133        let out = tool.execute(json!({"name": "demo"})).await.unwrap();
134        assert!(out.contains("# Skill: demo"));
135        assert!(out.contains("do the thing"));
136    }
137
138    #[tokio::test]
139    async fn unknown_skill_errors() {
140        let skills = Arc::new(vec![fake_skill("demo", "x")]);
141        let tool = SkillTool::new(skills);
142        let err = tool
143            .execute(json!({"name": "missing"}))
144            .await
145            .unwrap_err()
146            .to_string();
147        assert!(err.contains("unknown skill"));
148        assert!(err.contains("demo"));
149    }
150
151    #[tokio::test]
152    async fn missing_name_errors() {
153        let skills = Arc::new(vec![]);
154        let tool = SkillTool::new(skills);
155        let err = tool.execute(json!({})).await.unwrap_err().to_string();
156        assert!(err.contains("missing 'name'"));
157    }
158
159    #[test]
160    fn definition_enum_only_when_skills_present() {
161        let empty = SkillTool::new(Arc::new(vec![]));
162        let def = empty.definition();
163        assert!(def.parameters["properties"]["name"].get("enum").is_none());
164
165        let full = SkillTool::new(Arc::new(vec![fake_skill("a", ""), fake_skill("b", "")]));
166        let def = full.definition();
167        let names = def.parameters["properties"]["name"]["enum"]
168            .as_array()
169            .unwrap();
170        assert_eq!(names.len(), 2);
171    }
172}