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
71            .skills
72            .iter()
73            .find(|s| s.name == name)
74            .ok_or_else(|| {
75                let available: Vec<&str> = self.skills.iter().map(|s| s.name.as_str()).collect();
76                // spinner.finish_error("skill not found");
77                anyhow::anyhow!(
78                    "unknown skill '{}'. Available: {}",
79                    name,
80                    if available.is_empty() {
81                        "(none loaded)".to_string()
82                    } else {
83                        available.join(", ")
84                    }
85                )
86            })?;
87
88        let files = list_skill_files(&skill.dir);
89        let files_section = if files.is_empty() {
90            String::new()
91        } else {
92            let mut s = String::from("\n\n---\nFiles in this skill (read with the `read` tool, paths are relative to the skill directory):\n");
93            s.push_str(&format!("skill_dir: {}\n", skill.dir.display()));
94            for f in files {
95                s.push_str(&format!("- {}\n", f));
96            }
97            s
98        };
99
100        let result = format!(
101            "# Skill: {}\n\n{}{}",
102            skill.name,
103            skill.body.trim_end(),
104            files_section
105        );
106
107        // spinner.finish_success(&format!("skill '{}' loaded", name));
108        Ok(result)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::path::PathBuf;
116
117    fn fake_skill(name: &str, body: &str) -> Skill {
118        Skill {
119            name: name.to_string(),
120            description: "desc".to_string(),
121            dir: PathBuf::from("/nonexistent"),
122            body: body.to_string(),
123            source_file: PathBuf::from("/nonexistent/SKILL.md"),
124        }
125    }
126
127    #[tokio::test]
128    async fn returns_skill_body() {
129        let skills = Arc::new(vec![fake_skill("demo", "do the thing")]);
130        let tool = SkillTool::new(skills);
131        let out = tool.execute(json!({"name": "demo"})).await.unwrap();
132        assert!(out.contains("# Skill: demo"));
133        assert!(out.contains("do the thing"));
134    }
135
136    #[tokio::test]
137    async fn unknown_skill_errors() {
138        let skills = Arc::new(vec![fake_skill("demo", "x")]);
139        let tool = SkillTool::new(skills);
140        let err = tool
141            .execute(json!({"name": "missing"}))
142            .await
143            .unwrap_err()
144            .to_string();
145        assert!(err.contains("unknown skill"));
146        assert!(err.contains("demo"));
147    }
148
149    #[tokio::test]
150    async fn missing_name_errors() {
151        let skills = Arc::new(vec![]);
152        let tool = SkillTool::new(skills);
153        let err = tool.execute(json!({})).await.unwrap_err().to_string();
154        assert!(err.contains("missing 'name'"));
155    }
156
157    #[test]
158    fn definition_enum_only_when_skills_present() {
159        let empty = SkillTool::new(Arc::new(vec![]));
160        let def = empty.definition();
161        assert!(def.parameters["properties"]["name"].get("enum").is_none());
162
163        let full = SkillTool::new(Arc::new(vec![fake_skill("a", ""), fake_skill("b", "")]));
164        let def = full.definition();
165        let names = def.parameters["properties"]["name"]["enum"]
166            .as_array()
167            .unwrap();
168        assert_eq!(names.len(), 2);
169    }
170}