| 1 | //! `load_skill` tool — fetch a `SKILL.md` body and its companion-file |
| 2 | //! list into the model's context (#434). |
| 3 | //! |
| 4 | //! ## Why a tool when skills already surface in the system prompt? |
| 5 | //! |
| 6 | //! `prompts.rs::system_prompt_for_mode_with_context_and_skills` injects |
| 7 | //! a one-line listing of every available skill (name + description + |
| 8 | //! file path) so the model knows what's in the catalogue at the start |
| 9 | //! of every turn. The full body of each skill is *not* loaded — that |
| 10 | //! would blow the prompt budget the moment a user has half a dozen |
| 11 | //! skills installed. |
| 12 | //! |
| 13 | //! Two paths exist for the model to actually read a skill: |
| 14 | //! |
| 15 | //! 1. The existing progressive-disclosure pattern: model spots a |
| 16 | //! skill in the catalogue, calls `read_file <path>` from the |
| 17 | //! listing. |
| 18 | //! 2. (this tool) `load_skill name=<id>` — single call, name-based |
| 19 | //! lookup, also enumerates the sibling files in the skill's |
| 20 | //! directory so the model sees the companion resources without |
| 21 | //! a separate `list_dir`. |
| 22 | //! |
| 23 | //! Both are valid; the tool is the higher-level affordance and |
| 24 | //! avoids the two-call dance for skills that ship with multiple |
| 25 | //! resource files. |
| 26 | |
| 27 | use async_trait::async_trait; |
| 28 | use serde_json::{Value, json}; |
| 29 | |
| 30 | use crate::skills::{Skill, discover_in_workspace, skills_directories}; |
| 31 | |
| 32 | use super::spec::{ |
| 33 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 34 | }; |
| 35 | |
| 36 | pub struct LoadSkillTool; |
| 37 | |
| 38 | #[async_trait] |
| 39 | impl ToolSpec for LoadSkillTool { |
| 40 | fn name(&self) -> &'static str { |
| 41 | "load_skill" |
| 42 | } |
| 43 | |
| 44 | fn description(&self) -> &'static str { |
| 45 | "Load a skill (SKILL.md body + companion file list) into the next turn's context. \ |
| 46 | Use this when the user names a skill or the task clearly matches a skill listed in the system prompt's `## Skills` section. Faster than read_file + list_dir." |
| 47 | } |
| 48 | |
| 49 | fn input_schema(&self) -> Value { |
| 50 | json!({ |
| 51 | "type": "object", |
| 52 | "properties": { |
| 53 | "name": { |
| 54 | "type": "string", |
| 55 | "description": "Skill id (the `name` field from the SKILL.md frontmatter, also shown in the `## Skills` listing)." |
| 56 | } |
| 57 | }, |
| 58 | "required": ["name"], |
| 59 | "additionalProperties": false |
| 60 | }) |
| 61 | } |
| 62 | |
| 63 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 64 | vec![ToolCapability::ReadOnly] |
| 65 | } |
| 66 | |
| 67 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 68 | ApprovalRequirement::Auto |
| 69 | } |
| 70 | |
| 71 | fn supports_parallel(&self) -> bool { |
| 72 | true |
| 73 | } |
| 74 | |
| 75 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 76 | let name = input |
| 77 | .get("name") |
| 78 | .and_then(Value::as_str) |
| 79 | .ok_or_else(|| ToolError::missing_field("name"))? |
| 80 | .trim(); |
| 81 | if name.is_empty() { |
| 82 | return Err(ToolError::invalid_input( |
| 83 | "`name` must be a non-empty string", |
| 84 | )); |
| 85 | } |
| 86 | |
| 87 | // #432: walk every candidate skill directory (workspace |
| 88 | // .agents/skills, skills, .opencode/skills, .claude/skills, |
| 89 | // .cursor/skills, ~/.agents/skills, global default), merging with |
| 90 | // first-wins precedence. The |
| 91 | // tool's lookup mirrors what the system-prompt skills block |
| 92 | // already lists, so the model never asks for a name it |
| 93 | // can't find. |
| 94 | let registry = discover_in_workspace(&context.workspace); |
| 95 | let Some(skill) = registry.get(name) else { |
| 96 | let available: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect(); |
| 97 | let hint = if available.is_empty() { |
| 98 | let dirs: Vec<String> = skills_directories(&context.workspace) |
| 99 | .iter() |
| 100 | .map(|p| p.display().to_string()) |
| 101 | .collect(); |
| 102 | if dirs.is_empty() { |
| 103 | "no skills directories found; install skills under `<workspace>/.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md`, or `~/.deepseek/skills/<name>/SKILL.md`" |
| 104 | .to_string() |
| 105 | } else { |
| 106 | format!("no skills installed. Searched: {}", dirs.join(", ")) |
| 107 | } |
| 108 | } else { |
| 109 | format!( |
| 110 | "skill `{name}` not found. Available: {}", |
| 111 | available.join(", ") |
| 112 | ) |
| 113 | }; |
| 114 | return Err(ToolError::execution_failed(hint)); |
| 115 | }; |
| 116 | |
| 117 | let body = format_skill_body(skill); |
| 118 | Ok(ToolResult::success(body).with_metadata(json!({ |
| 119 | "skill_name": skill.name, |
| 120 | "skill_path": skill.path.display().to_string(), |
| 121 | "companion_files": collect_companion_files(skill) |
| 122 | .into_iter() |
| 123 | .map(|p| p.display().to_string()) |
| 124 | .collect::<Vec<String>>(), |
| 125 | }))) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /// Render the skill body the model will see. Includes the description |
| 130 | /// up top so a single tool result is self-contained — no need to |
| 131 | /// cross-reference the system-prompt catalogue. Companion-file paths |
| 132 | /// land at the bottom under a clearly-named heading so the model can |
| 133 | /// open them with `read_file` if they're relevant to the task. |
| 134 | fn format_skill_body(skill: &Skill) -> String { |
| 135 | let mut out = String::new(); |
| 136 | out.push_str(&format!("# Skill: {}\n\n", skill.name)); |
| 137 | if !skill.description.trim().is_empty() { |
| 138 | out.push_str(&format!("> {}\n\n", skill.description.trim())); |
| 139 | } |
| 140 | out.push_str(&format!("Source: `{}`\n\n", skill.path.display())); |
| 141 | out.push_str("## SKILL.md\n\n"); |
| 142 | out.push_str(skill.body.trim()); |
| 143 | out.push('\n'); |
| 144 | |
| 145 | let companions = collect_companion_files(skill); |
| 146 | if !companions.is_empty() { |
| 147 | out.push_str("\n## Companion files\n\n"); |
| 148 | out.push_str( |
| 149 | "Sibling files in the skill directory. Use `read_file` to open them when the task requires.\n\n", |
| 150 | ); |
| 151 | for path in &companions { |
| 152 | out.push_str(&format!("- `{}`\n", path.display())); |
| 153 | } |
| 154 | } |
| 155 | out |
| 156 | } |
| 157 | |
| 158 | /// List sibling files of `SKILL.md` in the skill's own directory. |
| 159 | /// Skips the `SKILL.md` itself and any nested directories so the |
| 160 | /// listing stays focused on at-hand resources. Sorted lexically for |
| 161 | /// deterministic output (matters for transcript diffing in tests). |
| 162 | fn collect_companion_files(skill: &Skill) -> Vec<std::path::PathBuf> { |
| 163 | let Some(dir) = skill.path.parent() else { |
| 164 | return Vec::new(); |
| 165 | }; |
| 166 | let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) { |
| 167 | Ok(rd) => rd |
| 168 | .flatten() |
| 169 | .filter_map(|entry| { |
| 170 | let path = entry.path(); |
| 171 | let is_file = entry.file_type().is_ok_and(|ft| ft.is_file()); |
| 172 | let is_skill_md = path.file_name().and_then(|s| s.to_str()) == Some("SKILL.md"); |
| 173 | if is_file && !is_skill_md { |
| 174 | Some(path) |
| 175 | } else { |
| 176 | None |
| 177 | } |
| 178 | }) |
| 179 | .collect(), |
| 180 | Err(_) => Vec::new(), |
| 181 | }; |
| 182 | entries.sort(); |
| 183 | entries |
| 184 | } |
| 185 | |
| 186 | #[cfg(test)] |
| 187 | mod tests { |
| 188 | use super::*; |
| 189 | use crate::skills::SkillRegistry; |
| 190 | use std::fs; |
| 191 | use tempfile::tempdir; |
| 192 | |
| 193 | fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) { |
| 194 | let skill_dir = dir.join(name); |
| 195 | fs::create_dir_all(&skill_dir).unwrap(); |
| 196 | fs::write( |
| 197 | skill_dir.join("SKILL.md"), |
| 198 | format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"), |
| 199 | ) |
| 200 | .unwrap(); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn load_skill_returns_skill_body_with_description_header() { |
| 205 | let tmp = tempdir().unwrap(); |
| 206 | write_skill( |
| 207 | tmp.path(), |
| 208 | "review-pr", |
| 209 | "Run a focused PR review", |
| 210 | "# Steps\n1. Read the diff.\n2. Comment.\n", |
| 211 | ); |
| 212 | let skill = SkillRegistry::discover(tmp.path()) |
| 213 | .get("review-pr") |
| 214 | .unwrap() |
| 215 | .clone(); |
| 216 | let body = format_skill_body(&skill); |
| 217 | assert!(body.contains("# Skill: review-pr")); |
| 218 | assert!(body.contains("Run a focused PR review")); |
| 219 | assert!(body.contains("# Steps")); |
| 220 | assert!(body.contains("Read the diff.")); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn collect_companion_files_lists_siblings_excluding_skill_md() { |
| 225 | let tmp = tempdir().unwrap(); |
| 226 | let skill_dir = tmp.path().join("rich-skill"); |
| 227 | fs::create_dir_all(&skill_dir).unwrap(); |
| 228 | fs::write( |
| 229 | skill_dir.join("SKILL.md"), |
| 230 | "---\nname: rich-skill\ndescription: x\n---\nbody\n", |
| 231 | ) |
| 232 | .unwrap(); |
| 233 | fs::write(skill_dir.join("script.py"), "print('hi')").unwrap(); |
| 234 | fs::write(skill_dir.join("data.json"), "{}").unwrap(); |
| 235 | // Nested directory — skipped by collect_companion_files. |
| 236 | fs::create_dir_all(skill_dir.join("subdir")).unwrap(); |
| 237 | |
| 238 | let registry = SkillRegistry::discover(tmp.path()); |
| 239 | let skill = registry.get("rich-skill").unwrap(); |
| 240 | let files = collect_companion_files(skill); |
| 241 | let names: Vec<String> = files |
| 242 | .iter() |
| 243 | .filter_map(|p| p.file_name().and_then(|s| s.to_str().map(str::to_string))) |
| 244 | .collect(); |
| 245 | assert_eq!( |
| 246 | names, |
| 247 | vec!["data.json".to_string(), "script.py".to_string()] |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn collect_companion_files_returns_empty_for_solo_skill() { |
| 253 | let tmp = tempdir().unwrap(); |
| 254 | write_skill(tmp.path(), "solo", "Just a skill", "body"); |
| 255 | let registry = SkillRegistry::discover(tmp.path()); |
| 256 | let skill = registry.get("solo").unwrap(); |
| 257 | assert!(collect_companion_files(skill).is_empty()); |
| 258 | } |
| 259 | |
| 260 | #[test] |
| 261 | fn format_skill_body_emits_companion_files_section_when_present() { |
| 262 | let tmp = tempdir().unwrap(); |
| 263 | let skill_dir = tmp.path().join("skill-with-friends"); |
| 264 | fs::create_dir_all(&skill_dir).unwrap(); |
| 265 | fs::write( |
| 266 | skill_dir.join("SKILL.md"), |
| 267 | "---\nname: skill-with-friends\ndescription: x\n---\nbody\n", |
| 268 | ) |
| 269 | .unwrap(); |
| 270 | fs::write(skill_dir.join("helper.sh"), "#!/bin/sh\necho hi").unwrap(); |
| 271 | |
| 272 | let registry = SkillRegistry::discover(tmp.path()); |
| 273 | let skill = registry.get("skill-with-friends").unwrap(); |
| 274 | let body = format_skill_body(skill); |
| 275 | assert!(body.contains("## Companion files")); |
| 276 | assert!(body.contains("helper.sh")); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn format_skill_body_skips_companion_section_when_solo() { |
| 281 | let tmp = tempdir().unwrap(); |
| 282 | write_skill(tmp.path(), "solo", "x", "body"); |
| 283 | let registry = SkillRegistry::discover(tmp.path()); |
| 284 | let skill = registry.get("solo").unwrap(); |
| 285 | let body = format_skill_body(skill); |
| 286 | assert!( |
| 287 | !body.contains("## Companion files"), |
| 288 | "solo skills shouldn't emit an empty Companion files section" |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | #[tokio::test] |
| 293 | async fn execute_finds_skills_in_opencode_dir_via_workspace_discovery() { |
| 294 | let tmp = tempdir().unwrap(); |
| 295 | let workspace = tmp.path().to_path_buf(); |
| 296 | // Skill installed under workspace `.opencode/skills` (#432). |
| 297 | let opencode_dir = workspace.join(".opencode").join("skills"); |
| 298 | std::fs::create_dir_all(&opencode_dir).unwrap(); |
| 299 | write_skill( |
| 300 | &opencode_dir, |
| 301 | "from-opencode", |
| 302 | "Skill installed under .opencode/skills", |
| 303 | "Body content marker.", |
| 304 | ); |
| 305 | |
| 306 | let mut context = ToolContext::new(workspace); |
| 307 | // The skill tool reads $HOME for the global default; pin it to a |
| 308 | // tempdir so the test is hermetic regardless of the host's |
| 309 | // ~/.deepseek/skills. |
| 310 | context.workspace = tmp.path().to_path_buf(); |
| 311 | |
| 312 | let tool = LoadSkillTool; |
| 313 | let result = tool |
| 314 | .execute(json!({"name": "from-opencode"}), &context) |
| 315 | .await |
| 316 | .expect("load_skill should succeed"); |
| 317 | assert!(result.success); |
| 318 | assert!( |
| 319 | result.content.contains("# Skill: from-opencode"), |
| 320 | "body header missing: {}", |
| 321 | &result.content |
| 322 | ); |
| 323 | assert!(result.content.contains("Body content marker.")); |
| 324 | |
| 325 | let metadata = result.metadata.expect("metadata stamped"); |
| 326 | assert_eq!( |
| 327 | metadata |
| 328 | .get("skill_name") |
| 329 | .and_then(serde_json::Value::as_str), |
| 330 | Some("from-opencode") |
| 331 | ); |
| 332 | let path_str = metadata |
| 333 | .get("skill_path") |
| 334 | .and_then(serde_json::Value::as_str) |
| 335 | .expect("skill_path stamped"); |
| 336 | assert!( |
| 337 | path_str.contains(".opencode"), |
| 338 | "skill_path should point at the .opencode dir: {path_str}" |
| 339 | ); |
| 340 | } |
| 341 | |
| 342 | #[tokio::test] |
| 343 | async fn execute_returns_helpful_error_for_unknown_skill() { |
| 344 | let tmp = tempdir().unwrap(); |
| 345 | let workspace = tmp.path().to_path_buf(); |
| 346 | // One real skill so the available list is non-empty. |
| 347 | write_skill( |
| 348 | &workspace.join(".agents").join("skills"), |
| 349 | "real-one", |
| 350 | "x", |
| 351 | "body", |
| 352 | ); |
| 353 | |
| 354 | let context = ToolContext::new(workspace); |
| 355 | let tool = LoadSkillTool; |
| 356 | let err = tool |
| 357 | .execute(json!({"name": "imaginary"}), &context) |
| 358 | .await |
| 359 | .expect_err("unknown skill should error"); |
| 360 | let msg = err.to_string(); |
| 361 | assert!( |
| 362 | msg.contains("imaginary") && msg.contains("real-one"), |
| 363 | "error must name the missing skill and list available ones: {msg}" |
| 364 | ); |
| 365 | } |
| 366 | } |
| 367 |