Skip to main content

llama_cpp_v3_agent_sdk/
skills.rs

1//! Skill discovery and loading.
2//!
3//! Skills are reusable prompt modules that extend the agent's capabilities.
4//! Each skill is a directory containing a `SKILL.md` file with YAML-like
5//! frontmatter (name + description) followed by Markdown instructions.
6//!
7//! Skills are discovered from:
8//! 1. `./.llama-agent/skills/`  — project-local
9//! 2. `./.agents/skills/`       — project-local (alt)
10//! 3. `~/.llama-agent/skills/`  — user-global
11//! 4. `~/.agents/skills/`       — user-global (alt)
12//! 5. Custom paths via `SkillRegistry::add_search_path`
13
14use std::path::{Path, PathBuf};
15use std::collections::HashMap;
16
17/// Metadata extracted from SKILL.md frontmatter.
18#[derive(Debug, Clone)]
19pub struct SkillMeta {
20    /// Unique skill name (from frontmatter `name:`).
21    pub name: String,
22    /// When/why to activate this skill (from frontmatter `description:`).
23    pub description: String,
24    /// Path to the skill directory.
25    pub path: PathBuf,
26}
27
28/// A fully loaded skill (metadata + instructions).
29#[derive(Debug, Clone)]
30pub struct Skill {
31    pub meta: SkillMeta,
32    /// Full Markdown body after the frontmatter.
33    pub instructions: String,
34    /// JSON Schemas found in the `schemas/` subdirectory.
35    pub schemas: HashMap<String, String>,
36    /// Markdown references found in the `references/` subdirectory.
37    pub references: HashMap<String, String>,
38}
39
40/// Registry that discovers skills from multiple search paths.
41pub struct SkillRegistry {
42    search_paths: Vec<PathBuf>,
43    /// Discovered skill metadata (loaded lazily by name).
44    discovered: Vec<SkillMeta>,
45    /// Fully loaded skills (activated on demand).
46    loaded: Vec<Skill>,
47}
48
49impl SkillRegistry {
50    pub fn new() -> Self {
51        Self {
52            search_paths: Vec::new(),
53            discovered: Vec::new(),
54            loaded: Vec::new(),
55        }
56    }
57
58    /// Create a registry pre-populated with the default search paths.
59    pub fn with_defaults() -> Self {
60        let mut reg = Self::new();
61        reg.add_default_paths();
62        reg
63    }
64
65    /// Add the standard search paths (project-local + user-global).
66    pub fn add_default_paths(&mut self) {
67        // Project-local
68        if let Ok(cwd) = std::env::current_dir() {
69            self.search_paths.push(cwd.join(".llama-agent").join("skills"));
70            self.search_paths.push(cwd.join(".agents").join("skills"));
71        }
72        // User-global
73        if let Some(home) = dirs::home_dir() {
74            self.search_paths.push(home.join(".llama-agent").join("skills"));
75            self.search_paths.push(home.join(".agents").join("skills"));
76        }
77    }
78
79    /// Add an extra search path for skills.
80    pub fn add_search_path(&mut self, path: PathBuf) {
81        self.search_paths.push(path);
82    }
83
84    /// Scan all search paths and discover skills (reads only frontmatter).
85    pub fn discover(&mut self) {
86        self.discovered.clear();
87        for search_path in &self.search_paths {
88            if !search_path.is_dir() {
89                continue;
90            }
91            if let Ok(entries) = std::fs::read_dir(search_path) {
92                for entry in entries.flatten() {
93                    let skill_dir = entry.path();
94                    if skill_dir.is_dir() {
95                        let skill_md = skill_dir.join("SKILL.md");
96                        if skill_md.is_file() {
97                            if let Some(meta) = parse_skill_frontmatter(&skill_md, &skill_dir) {
98                                // Dedup by name (first found wins)
99                                if !self.discovered.iter().any(|s| s.name == meta.name) {
100                                    self.discovered.push(meta);
101                                }
102                            }
103                        }
104                    }
105                }
106            }
107        }
108    }
109
110    /// Return all discovered skill metadata.
111    pub fn discovered(&self) -> &[SkillMeta] {
112        &self.discovered
113    }
114
115    /// Load (activate) a skill by name — reads the full SKILL.md body.
116    pub fn load(&mut self, name: &str) -> Option<&Skill> {
117        // Already loaded?
118        if let Some(idx) = self.loaded.iter().position(|s| s.meta.name == name) {
119            return Some(&self.loaded[idx]);
120        }
121
122        // Find in discovered
123        let meta = self.discovered.iter().find(|s| s.name == name)?.clone();
124        let skill_md = meta.path.join("SKILL.md");
125        let content = std::fs::read_to_string(&skill_md).ok()?;
126        let instructions = strip_frontmatter(&content);
127
128        // Load schemas
129        let mut schemas = HashMap::new();
130        let schemas_dir = meta.path.join("schemas");
131        if schemas_dir.is_dir() {
132            if let Ok(entries) = std::fs::read_dir(schemas_dir) {
133                for entry in entries.flatten() {
134                    let path = entry.path();
135                    if path.extension().and_then(|s| s.to_str()) == Some("json") {
136                        if let (
137                            Some(name),
138                            Ok(content),
139                        ) = (
140                            path.file_name().and_then(|s| s.to_str()),
141                            std::fs::read_to_string(&path),
142                        ) {
143                            schemas.insert(name.to_string(), content);
144                        }
145                    }
146                }
147            }
148        }
149
150        // Load references
151        let mut references = HashMap::new();
152        let refs_dir = meta.path.join("references");
153        if refs_dir.is_dir() {
154            if let Ok(entries) = std::fs::read_dir(refs_dir) {
155                for entry in entries.flatten() {
156                    let path = entry.path();
157                    if path.extension().and_then(|s| s.to_str()) == Some("md") {
158                        if let (
159                            Some(name),
160                            Ok(content),
161                        ) = (
162                            path.file_name().and_then(|s| s.to_str()),
163                            std::fs::read_to_string(&path),
164                        ) {
165                            references.insert(name.to_string(), content);
166                        }
167                    }
168                }
169            }
170        }
171
172        let skill = Skill {
173            meta,
174            instructions,
175            schemas,
176            references,
177        };
178        self.loaded.push(skill);
179        self.loaded.last()
180    }
181
182    /// Load all discovered skills.
183    pub fn load_all(&mut self) {
184        let names: Vec<String> = self.discovered.iter().map(|s| s.name.clone()).collect();
185        for name in names {
186            self.load(&name);
187        }
188    }
189
190    /// Get all loaded (activated) skills.
191    pub fn loaded(&self) -> &[Skill] {
192        &self.loaded
193    }
194
195    /// Generate a system prompt fragment listing discovered skills.
196    ///
197    /// Only name + description are included (the full instructions are
198    /// injected when a skill is activated).
199    pub fn skills_summary_prompt(&self) -> String {
200        if self.discovered.is_empty() {
201            return String::new();
202        }
203        let mut lines = Vec::new();
204        lines.push("# Available Skills".to_string());
205        lines.push("The following skills are available. They will be activated when relevant:\n".to_string());
206        for skill in &self.discovered {
207            lines.push(format!("- **{}**: {}", skill.name, skill.description));
208        }
209        lines.push(String::new());
210        lines.join("\n")
211    }
212
213    /// Generate a prompt fragment with the full instructions of all loaded skills.
214    pub fn loaded_skills_prompt(&self) -> String {
215        if self.loaded.is_empty() {
216            return String::new();
217        }
218        let mut lines = Vec::new();
219        lines.push("# Active Skill Details\n".to_string());
220        for skill in &self.loaded {
221            lines.push(format!("## Skill: {}\n", skill.meta.name));
222            lines.push(skill.instructions.clone());
223            lines.push(String::new());
224
225            if !skill.schemas.is_empty() {
226                lines.push("### Required Output Schemas".to_string());
227                lines.push("When generating JSON, you MUST adhere to these schemas:".to_string());
228                for (name, content) in &skill.schemas {
229                    lines.push(format!("\n#### Schema: {}\n```json\n{}\n```", name, content));
230                }
231                lines.push(String::new());
232            }
233
234            if !skill.references.is_empty() {
235                lines.push("### References & Guidelines".to_string());
236                for (name, content) in &skill.references {
237                    lines.push(format!("\n#### Reference: {}\n{}", name, content));
238                }
239                lines.push(String::new());
240            }
241        }
242        lines.join("\n")
243    }
244}
245
246impl Default for SkillRegistry {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252// ─── Frontmatter parsing ──────────────────────────────────────────────────
253
254/// Parse YAML-like frontmatter from a SKILL.md file.
255///
256/// Expected format:
257/// ```text
258/// ---
259/// name: skill-name
260/// description: What and when
261/// ---
262/// Markdown body...
263/// ```
264fn parse_skill_frontmatter(skill_md: &Path, skill_dir: &Path) -> Option<SkillMeta> {
265    let content = std::fs::read_to_string(skill_md).ok()?;
266    let content = content.trim();
267
268    if !content.starts_with("---") {
269        return None;
270    }
271
272    // Find closing ---
273    let rest = &content[3..];
274    let end = rest.find("---")?;
275    let frontmatter = &rest[..end];
276
277    let mut name = None;
278    let mut description = None;
279
280    for line in frontmatter.lines() {
281        let line = line.trim();
282        if let Some(val) = line.strip_prefix("name:") {
283            name = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
284        } else if let Some(val) = line.strip_prefix("description:") {
285            description = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
286        }
287    }
288
289    Some(SkillMeta {
290        name: name?,
291        description: description.unwrap_or_default(),
292        path: skill_dir.to_path_buf(),
293    })
294}
295
296/// Strip frontmatter (everything between opening and closing `---`) from content.
297fn strip_frontmatter(content: &str) -> String {
298    let content = content.trim();
299    if !content.starts_with("---") {
300        return content.to_string();
301    }
302    let rest = &content[3..];
303    if let Some(end) = rest.find("---") {
304        rest[end + 3..].trim().to_string()
305    } else {
306        content.to_string()
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_strip_frontmatter() {
316        let content = "---\nname: test\ndescription: A test skill\n---\n\n# Instructions\nDo something.";
317        let body = strip_frontmatter(content);
318        assert!(body.starts_with("# Instructions"));
319        assert!(body.contains("Do something."));
320    }
321
322    #[test]
323    fn test_strip_frontmatter_no_frontmatter() {
324        let content = "Just regular markdown.";
325        assert_eq!(strip_frontmatter(content), content);
326    }
327}