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": "要加载的技能名称(必须匹配系统提示中列出的名称)"
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                "加载指定技能的完整指令。当系统提示中列出的技能与用户请求相关时调用此工具。\
52                 返回包含技能内容和技能目录中的文件列表,可用 `read` 工具读取。"
53                    .to_string(),
54            parameters: json!({
55                "type": "object",
56                "properties": props,
57                "required": ["name"]
58            }),
59        }
60    }
61
62    async fn execute(&self, params: Value) -> Result<String> {
63        let name = params["name"]
64            .as_str()
65            .ok_or_else(|| anyhow::anyhow!("missing 'name'"))?;
66
67        // Show spinner while loading skill - RAII guard ensures cleanup on error
68        // let mut spinner = ToolSpinner::new(&format!("loading skill '{}'", name));
69
70        let skill = self.skills.iter().find(|s| s.name == name).ok_or_else(|| {
71            let available: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
72            // spinner.finish_error("skill not found");
73            anyhow::anyhow!(
74                "unknown skill '{}'. Available: {}",
75                name,
76                if available.is_empty() {
77                    "(none loaded)".to_string()
78                } else {
79                    available.join(", ")
80                }
81            )
82        })?;
83
84        let files = list_skill_files(&skill.dir);
85        let files_section = if files.is_empty() {
86            String::new()
87        } else {
88            let mut s = String::from(
89                "\n\n---\nFiles in this skill (read with the `read` tool, paths are relative to the skill directory):\n",
90            );
91            s.push_str(&format!("skill_dir: {}\n", skill.dir.display()));
92            for f in files {
93                s.push_str(&format!("- {}\n", f));
94            }
95            s
96        };
97
98        let result = format!(
99            "# Skill: {}\n\n{}{}",
100            skill.name,
101            skill.body.trim_end(),
102            files_section
103        );
104
105        // spinner.finish_success(&format!("skill '{}' loaded", name));
106        Ok(result)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::path::PathBuf;
114
115    fn fake_skill(name: &str, body: &str) -> Skill {
116        Skill {
117            name: name.to_string(),
118            description: "desc".to_string(),
119            dir: PathBuf::from("/nonexistent"),
120            body: body.to_string(),
121            source_file: PathBuf::from("/nonexistent/SKILL.md"),
122        }
123    }
124
125    #[tokio::test]
126    async fn returns_skill_body() {
127        let skills = Arc::new(vec![fake_skill("demo", "do the thing")]);
128        let tool = SkillTool::new(skills);
129        let out = tool.execute(json!({"name": "demo"})).await.unwrap();
130        assert!(out.contains("# Skill: demo"));
131        assert!(out.contains("do the thing"));
132    }
133
134    #[tokio::test]
135    async fn unknown_skill_errors() {
136        let skills = Arc::new(vec![fake_skill("demo", "x")]);
137        let tool = SkillTool::new(skills);
138        let err = tool
139            .execute(json!({"name": "missing"}))
140            .await
141            .unwrap_err()
142            .to_string();
143        assert!(err.contains("unknown skill"));
144        assert!(err.contains("demo"));
145    }
146
147    #[tokio::test]
148    async fn missing_name_errors() {
149        let skills = Arc::new(vec![]);
150        let tool = SkillTool::new(skills);
151        let err = tool.execute(json!({})).await.unwrap_err().to_string();
152        assert!(err.contains("missing 'name'"));
153    }
154
155    #[test]
156    fn definition_enum_only_when_skills_present() {
157        let empty = SkillTool::new(Arc::new(vec![]));
158        let def = empty.definition();
159        assert!(def.parameters["properties"]["name"].get("enum").is_none());
160
161        let full = SkillTool::new(Arc::new(vec![fake_skill("a", ""), fake_skill("b", "")]));
162        let def = full.definition();
163        let names = def.parameters["properties"]["name"]["enum"]
164            .as_array()
165            .unwrap();
166        assert_eq!(names.len(), 2);
167    }
168}