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