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