supercode_harness/tools/
skill.rs1use async_trait::async_trait;
12use serde::Deserialize;
13use serde_json::{json, Value};
14
15use crate::error::{Error, Result};
16use crate::skills::LoopSkill;
17use crate::tools::{Tool, ToolContext};
18
19pub const SKILL_TOOL: &str = "skill";
21
22const MAX_SUGGESTIONS: usize = 40;
24
25pub struct SkillTool {
34 skills: Vec<LoopSkill>,
35 shell: crate::skills::ShellInjection,
39}
40
41#[derive(Deserialize)]
42struct SkillArgs {
43 name: String,
44 #[serde(default)]
45 arguments: Option<String>,
46}
47
48impl SkillTool {
49 pub fn new(skills: Vec<LoopSkill>) -> Self {
52 Self {
53 skills,
54 shell: crate::skills::ShellInjection::disabled(),
55 }
56 }
57
58 pub fn with_shell(mut self, shell: crate::skills::ShellInjection) -> Self {
62 self.shell = shell;
63 self
64 }
65
66 pub fn skills(&self) -> &[LoopSkill] {
68 &self.skills
69 }
70
71 pub fn find(&self, name: &str) -> Option<&LoopSkill> {
74 crate::skills::find_skill(&self.skills, name)
75 }
76
77 fn unknown(&self, name: &str) -> Error {
80 let mut names: Vec<&str> = self
81 .skills
82 .iter()
83 .map(|skill| skill.name.as_str())
84 .take(MAX_SUGGESTIONS)
85 .collect();
86 names.sort_unstable();
87 let known = if names.is_empty() {
88 "no skills are installed in the roots this config reads".to_string()
89 } else {
90 format!("known skills: {}", names.join(", "))
91 };
92 Error::tool(SKILL_TOOL, format!("no skill named `{name}` — {known}"))
93 }
94}
95
96#[async_trait]
97impl Tool for SkillTool {
98 fn name(&self) -> &str {
99 SKILL_TOOL
100 }
101
102 fn description(&self) -> &str {
103 "Load a skill's full instructions on demand. The system prompt lists only each \
104 skill's name and description; call this with a name from that list to read the \
105 skill's body into the conversation, then follow it. `arguments` is substituted \
106 for `$ARGUMENTS` in the body."
107 }
108
109 fn parameters(&self) -> Value {
110 json!({
111 "type": "object",
112 "properties": {
113 "name": {
114 "type": "string",
115 "description": "Skill name exactly as the skills list gives it (qualified `dir:skill` / `plugin:skill` names included)."
116 },
117 "arguments": {
118 "type": "string",
119 "description": "Text substituted for `$ARGUMENTS` (and `$1`..`$9`) inside the skill body."
120 }
121 },
122 "required": ["name"],
123 "additionalProperties": false
124 })
125 }
126
127 async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
128 let a: SkillArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
129 tool: SKILL_TOOL.to_string(),
130 message: e.to_string(),
131 })?;
132 let skill = self.find(&a.name).ok_or_else(|| self.unknown(&a.name))?;
133 let arguments = a.arguments.unwrap_or_default();
134 let body = skill
135 .body_with_shell(&arguments, &self.shell)
136 .map_err(|e| Error::tool(SKILL_TOOL, format!("{}: {e}", skill.manifest.display())))?;
137 Ok(crate::skills::render_skill(skill, &body))
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::skills::SkillScope;
145 use std::path::PathBuf;
146
147 fn skill(name: &str, dir: PathBuf) -> LoopSkill {
148 LoopSkill {
149 name: name.to_string(),
150 description: Some("does a thing".into()),
151 version: None,
152 scope: SkillScope::Project,
153 manifest: dir.join("SKILL.md"),
154 dir,
155 model_invocable: true,
156 allowed_tools: Vec::new(),
157 argument_names: Vec::new(),
158 argument_hint: None,
159 }
160 }
161
162 #[test]
163 fn qualified_names_resolve_by_leaf_only_when_unambiguous() {
164 let tool = SkillTool::new(vec![
165 skill("infra:deploy", PathBuf::from("/tmp/a")),
166 skill("release", PathBuf::from("/tmp/b")),
167 ]);
168 assert_eq!(tool.find("infra:deploy").unwrap().name, "infra:deploy");
169 assert_eq!(tool.find("deploy").unwrap().name, "infra:deploy");
170 assert_eq!(tool.find("RELEASE").unwrap().name, "release");
171 assert!(tool.find("nope").is_none());
172
173 let ambiguous = SkillTool::new(vec![
174 skill("infra:deploy", PathBuf::from("/tmp/a")),
175 skill("web:deploy", PathBuf::from("/tmp/b")),
176 ]);
177 assert!(
178 ambiguous.find("deploy").is_none(),
179 "an ambiguous leaf must be refused, not silently guessed"
180 );
181 }
182
183 #[test]
184 fn an_unknown_name_names_the_discovered_set() {
185 let tool = SkillTool::new(vec![skill("release", PathBuf::from("/tmp/b"))]);
186 let message = tool.unknown("deploy").to_string();
187 assert!(message.contains("deploy"), "{message}");
188 assert!(message.contains("release"), "{message}");
189 }
190}