llama_cpp_v3_agent_sdk/
skills.rs1use std::path::{Path, PathBuf};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone)]
19pub struct SkillMeta {
20 pub name: String,
22 pub description: String,
24 pub path: PathBuf,
26}
27
28#[derive(Debug, Clone)]
30pub struct Skill {
31 pub meta: SkillMeta,
32 pub instructions: String,
34 pub schemas: HashMap<String, String>,
36 pub references: HashMap<String, String>,
38}
39
40pub struct SkillRegistry {
42 search_paths: Vec<PathBuf>,
43 discovered: Vec<SkillMeta>,
45 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 pub fn with_defaults() -> Self {
60 let mut reg = Self::new();
61 reg.add_default_paths();
62 reg
63 }
64
65 pub fn add_default_paths(&mut self) {
67 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 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 pub fn add_search_path(&mut self, path: PathBuf) {
81 self.search_paths.push(path);
82 }
83
84 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 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 pub fn discovered(&self) -> &[SkillMeta] {
112 &self.discovered
113 }
114
115 pub fn load(&mut self, name: &str) -> Option<&Skill> {
117 if let Some(idx) = self.loaded.iter().position(|s| s.meta.name == name) {
119 return Some(&self.loaded[idx]);
120 }
121
122 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 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 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 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 pub fn loaded(&self) -> &[Skill] {
192 &self.loaded
193 }
194
195 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 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
252fn 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 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
296fn 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}