Skip to main content

sgr_agent/
skills.rs

1//! Skills — domain-specific prompt fragments loaded from SKILL.md files.
2//!
3//! A skill is a YAML-frontmatter markdown file providing procedural instructions
4//! for a specific task type. Skills replace hardcoded example strings with
5//! hot-reloadable, structured prompt fragments.
6//!
7//! ## Format (SKILL.md)
8//! ```markdown
9//! ---
10//! name: crm-lookup
11//! description: CRM data queries — find contacts, count entries
12//! triggers: [intent_query, crm]
13//! priority: 10
14//! keywords: [lookup, find, count, search]
15//! ---
16//!
17//! WORKFLOW:
18//!   1. Search for the target...
19//!   2. Read the found file...
20//! ```
21//!
22//! ## Directory layout
23//! ```text
24//! skills/
25//! ├── crm-lookup/
26//! │   ├── SKILL.md
27//! │   └── references/     # optional supporting docs
28//! ├── inbox-processing/
29//! │   └── SKILL.md
30//! ```
31
32use std::path::{Path, PathBuf};
33
34// ── Frontmatter parsing (shared with tasks.rs) ─────────────────────────
35
36/// Split content into (frontmatter, body). Frontmatter is between `---` markers.
37pub fn split_frontmatter(content: &str) -> Option<(String, String)> {
38    let trimmed = content.trim_start();
39    if !trimmed.starts_with("---") {
40        return Some((String::new(), content.to_string()));
41    }
42    let after_first = &trimmed[3..].trim_start_matches(['\r', '\n']);
43    let end = after_first.find("\n---")?;
44    let frontmatter = after_first[..end].to_string();
45    let body = after_first[end + 4..].to_string();
46    Some((frontmatter, body))
47}
48
49/// Extract a simple `key: value` field from YAML-ish frontmatter.
50pub fn extract_field(frontmatter: &str, key: &str) -> Option<String> {
51    for line in frontmatter.lines() {
52        let line = line.trim();
53        if let Some(rest) = line.strip_prefix(key) {
54            let rest = rest.trim_start();
55            if let Some(value) = rest.strip_prefix(':') {
56                return Some(
57                    value
58                        .trim()
59                        .trim_matches('"')
60                        .trim_matches('\'')
61                        .to_string(),
62                );
63            }
64        }
65    }
66    None
67}
68
69/// Extract a `key: [a, b, c]` string list from frontmatter.
70pub fn extract_string_list(frontmatter: &str, key: &str) -> Vec<String> {
71    let Some(value) = extract_field(frontmatter, key) else {
72        return vec![];
73    };
74    let trimmed = value.trim().trim_start_matches('[').trim_end_matches(']');
75    if trimmed.is_empty() {
76        return vec![];
77    }
78    trimmed
79        .split(',')
80        .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
81        .filter(|s| !s.is_empty())
82        .collect()
83}
84
85// ── Skill struct ────────────────────────────────────────────────────────
86
87/// A loaded skill with parsed metadata and body.
88#[derive(Debug, Clone)]
89pub struct Skill {
90    pub name: String,
91    pub description: String,
92    /// Classification labels or intents that trigger this skill (push model).
93    pub triggers: Vec<String>,
94    /// Higher priority wins when multiple skills match.
95    pub priority: u32,
96    /// Keyword hints for disambiguation within same trigger group.
97    pub keywords: Vec<String>,
98    /// The markdown body — procedural instructions + examples.
99    pub body: String,
100    /// Path to SKILL.md on disk (None for compiled-in skills).
101    pub path: Option<PathBuf>,
102}
103
104/// Parse a SKILL.md string into a Skill struct.
105pub fn parse_skill(content: &str) -> Option<Skill> {
106    let (frontmatter, body) = split_frontmatter(content)?;
107    if frontmatter.is_empty() {
108        return None; // No frontmatter = not a valid skill
109    }
110
111    let name = extract_field(&frontmatter, "name")?;
112    let description = extract_field(&frontmatter, "description").unwrap_or_default();
113    let priority = extract_field(&frontmatter, "priority")
114        .and_then(|p| p.parse().ok())
115        .unwrap_or(1);
116    let triggers = extract_string_list(&frontmatter, "triggers");
117    let keywords = extract_string_list(&frontmatter, "keywords");
118
119    Some(Skill {
120        name,
121        description,
122        triggers,
123        priority,
124        keywords,
125        body: body.trim().to_string(),
126        path: None,
127    })
128}
129
130// ── Skill loading ───────────────────────────────────────────────────────
131
132/// Load all skills from a directory. Each subdirectory must contain SKILL.md.
133pub fn load_skills_from_dir(dir: &Path) -> Vec<Skill> {
134    let mut skills = Vec::new();
135    let Ok(entries) = std::fs::read_dir(dir) else {
136        return skills;
137    };
138    for entry in entries.flatten() {
139        let skill_path = entry.path().join("SKILL.md");
140        if !skill_path.exists() {
141            continue;
142        }
143        let Ok(content) = std::fs::read_to_string(&skill_path) else {
144            continue;
145        };
146        if let Some(mut skill) = parse_skill(&content) {
147            skill.path = Some(skill_path);
148            skills.push(skill);
149        }
150    }
151    skills.sort_by(|a, b| b.priority.cmp(&a.priority));
152    skills
153}
154
155// ── Skill registry ──────────────────────────────────────────────────────
156
157/// Registry of loaded skills with selection logic.
158#[derive(Debug, Default)]
159pub struct SkillRegistry {
160    skills: Vec<Skill>,
161}
162
163impl SkillRegistry {
164    pub fn new() -> Self {
165        Self { skills: Vec::new() }
166    }
167
168    /// Create from a pre-loaded skill list.
169    pub fn from_skills(mut skills: Vec<Skill>) -> Self {
170        skills.sort_by(|a, b| b.priority.cmp(&a.priority));
171        Self { skills }
172    }
173
174    /// Load from directory (hot-reload for development).
175    pub fn from_dir(dir: &Path) -> Self {
176        Self {
177            skills: load_skills_from_dir(dir),
178        }
179    }
180
181    /// Number of loaded skills.
182    pub fn len(&self) -> usize {
183        self.skills.len()
184    }
185
186    pub fn is_empty(&self) -> bool {
187        self.skills.is_empty()
188    }
189
190    /// Push model: select best skill for given classification labels.
191    /// Matches triggers against any of the provided labels.
192    /// When multiple match, uses keyword hints from instruction, then priority.
193    pub fn select(&self, labels: &[&str], instruction: &str) -> Option<&Skill> {
194        // Phase 1: filter by trigger match
195        let mut candidates: Vec<&Skill> = self
196            .skills
197            .iter()
198            .filter(|s| s.triggers.iter().any(|t| labels.contains(&t.as_str())))
199            .collect();
200
201        if candidates.is_empty() {
202            return None;
203        }
204
205        // Phase 2: prefer keyword match in instruction
206        if candidates.len() > 1 {
207            let instr_lower = instruction.to_lowercase();
208            let keyword_match: Vec<&Skill> = candidates
209                .iter()
210                .filter(|s| {
211                    !s.keywords.is_empty()
212                        && s.keywords
213                            .iter()
214                            .any(|kw| instr_lower.contains(&kw.to_lowercase()))
215                })
216                .copied()
217                .collect();
218            if !keyword_match.is_empty() {
219                candidates = keyword_match;
220            }
221        }
222
223        // Phase 3: highest priority wins (already sorted)
224        candidates.first().copied()
225    }
226
227    /// Pull model: get skill by exact name.
228    pub fn get(&self, name: &str) -> Option<&Skill> {
229        self.skills.iter().find(|s| s.name == name)
230    }
231
232    /// List all skill names and descriptions (for agent self-discovery).
233    pub fn list(&self) -> Vec<(&str, &str)> {
234        self.skills
235            .iter()
236            .map(|s| (s.name.as_str(), s.description.as_str()))
237            .collect()
238    }
239
240    /// All skills (for iteration).
241    pub fn skills(&self) -> &[Skill] {
242        &self.skills
243    }
244}
245
246// ── Skill tools (agent feature) ─────────────────────────────────────────
247
248#[cfg(feature = "agent")]
249mod skill_tools {
250    use super::*;
251    use crate::agent_tool::{Tool, ToolError, ToolOutput};
252    use crate::context::AgentContext;
253    use async_trait::async_trait;
254    use serde_json::Value;
255    use std::sync::Arc;
256
257    /// List available skills — agent can discover alternative workflows mid-task.
258    pub struct ListSkillsTool(pub Arc<SkillRegistry>);
259
260    #[async_trait]
261    impl Tool for ListSkillsTool {
262        fn name(&self) -> &str {
263            "list_skills"
264        }
265        fn description(&self) -> &str {
266            "List all available skill workflows. Use when current instructions don't match the task."
267        }
268        fn is_read_only(&self) -> bool {
269            true
270        }
271        fn parameters_schema(&self) -> Value {
272            serde_json::json!({ "type": "object", "properties": {} })
273        }
274        async fn execute(
275            &self,
276            _args: Value,
277            _ctx: &mut AgentContext,
278        ) -> Result<ToolOutput, ToolError> {
279            let list = self.0.list();
280            let text = list
281                .iter()
282                .map(|(name, desc)| format!("- {}: {}", name, desc))
283                .collect::<Vec<_>>()
284                .join("\n");
285            Ok(ToolOutput::text(format!(
286                "Available skills:\n{}\n\nUse get_skill(name) to load full instructions.",
287                text
288            )))
289        }
290    }
291
292    /// Get full instructions for a specific skill by name.
293    pub struct GetSkillTool(pub Arc<SkillRegistry>);
294
295    #[async_trait]
296    impl Tool for GetSkillTool {
297        fn name(&self) -> &str {
298            "get_skill"
299        }
300        fn description(&self) -> &str {
301            "Load full instructions for a specific skill. Use after list_skills to switch to correct workflow."
302        }
303        fn is_read_only(&self) -> bool {
304            true
305        }
306        fn parameters_schema(&self) -> Value {
307            serde_json::json!({
308                "type": "object",
309                "properties": {
310                    "name": { "type": "string", "description": "Skill name from list_skills" }
311                },
312                "required": ["name"]
313            })
314        }
315        async fn execute(
316            &self,
317            args: Value,
318            _ctx: &mut AgentContext,
319        ) -> Result<ToolOutput, ToolError> {
320            let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
321            match self.0.get(name) {
322                Some(skill) => Ok(ToolOutput::text(format!(
323                    "# Skill: {}\n{}\n\n---\n{}",
324                    skill.name, skill.description, skill.body
325                ))),
326                None => Err(ToolError::Execution(format!(
327                    "Skill '{}' not found. Use list_skills to see available skills.",
328                    name
329                ))),
330            }
331        }
332    }
333}
334
335#[cfg(feature = "agent")]
336pub use skill_tools::{GetSkillTool, ListSkillsTool};
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    const SAMPLE_SKILL: &str = "\
343---
344name: test-skill
345description: A test skill for unit testing
346triggers: [crm, intent_query]
347priority: 10
348keywords: [lookup, find]
349---
350
351WORKFLOW:
352  1. Search for the target
353  2. Read the found file
354
355EXAMPLE:
356  search({}) → result
357  answer({})";
358
359    #[test]
360    fn parse_basic() {
361        let skill = parse_skill(SAMPLE_SKILL).unwrap();
362        assert_eq!(skill.name, "test-skill");
363        assert_eq!(skill.description, "A test skill for unit testing");
364        assert_eq!(skill.triggers, vec!["crm", "intent_query"]);
365        assert_eq!(skill.priority, 10);
366        assert_eq!(skill.keywords, vec!["lookup", "find"]);
367        assert!(skill.body.contains("WORKFLOW:"));
368        assert!(skill.body.contains("EXAMPLE:"));
369    }
370
371    #[test]
372    fn parse_no_frontmatter() {
373        assert!(parse_skill("just body text").is_none());
374    }
375
376    #[test]
377    fn parse_no_name() {
378        let content = "---\ndescription: no name\n---\nbody";
379        assert!(parse_skill(content).is_none());
380    }
381
382    #[test]
383    fn parse_minimal() {
384        let content = "---\nname: minimal\n---\nbody";
385        let skill = parse_skill(content).unwrap();
386        assert_eq!(skill.name, "minimal");
387        assert_eq!(skill.priority, 1);
388        assert!(skill.triggers.is_empty());
389    }
390
391    #[test]
392    fn split_frontmatter_basic() {
393        let (fm, body) = split_frontmatter("---\nname: x\n---\nbody").unwrap();
394        assert!(fm.contains("name: x"));
395        assert!(body.contains("body"));
396    }
397
398    #[test]
399    fn split_frontmatter_no_markers() {
400        let (fm, body) = split_frontmatter("just text").unwrap();
401        assert!(fm.is_empty());
402        assert_eq!(body, "just text");
403    }
404
405    #[test]
406    fn extract_field_basic() {
407        let fm = "name: hello\ndescription: world";
408        assert_eq!(extract_field(fm, "name"), Some("hello".into()));
409        assert_eq!(extract_field(fm, "description"), Some("world".into()));
410        assert_eq!(extract_field(fm, "missing"), None);
411    }
412
413    #[test]
414    fn extract_field_quoted() {
415        let fm = "name: \"quoted value\"";
416        assert_eq!(extract_field(fm, "name"), Some("quoted value".into()));
417    }
418
419    #[test]
420    fn extract_string_list_basic() {
421        let fm = "triggers: [crm, intent_query, injection]";
422        assert_eq!(
423            extract_string_list(fm, "triggers"),
424            vec!["crm", "intent_query", "injection"]
425        );
426    }
427
428    #[test]
429    fn extract_string_list_empty() {
430        let fm = "triggers: []";
431        assert!(extract_string_list(fm, "triggers").is_empty());
432    }
433
434    #[test]
435    fn registry_select_by_trigger() {
436        let skills = vec![
437            parse_skill("---\nname: a\ntriggers: [crm]\npriority: 1\n---\nA body").unwrap(),
438            parse_skill("---\nname: b\ntriggers: [injection]\npriority: 1\n---\nB body").unwrap(),
439        ];
440        let reg = SkillRegistry::from_skills(skills);
441        let selected = reg.select(&["injection"], "test").unwrap();
442        assert_eq!(selected.name, "b");
443    }
444
445    #[test]
446    fn registry_select_by_keyword() {
447        let skills = vec![
448            parse_skill("---\nname: general\ntriggers: [crm]\npriority: 1\n---\nGeneral").unwrap(),
449            parse_skill("---\nname: invoice\ntriggers: [crm]\npriority: 20\nkeywords: [invoice, resend]\n---\nInvoice").unwrap(),
450        ];
451        let reg = SkillRegistry::from_skills(skills);
452        let selected = reg.select(&["crm"], "resend the invoice please").unwrap();
453        assert_eq!(selected.name, "invoice");
454    }
455
456    #[test]
457    fn registry_select_fallback_priority() {
458        let skills = vec![
459            parse_skill("---\nname: low\ntriggers: [crm]\npriority: 1\n---\nLow").unwrap(),
460            parse_skill("---\nname: high\ntriggers: [crm]\npriority: 50\n---\nHigh").unwrap(),
461        ];
462        let reg = SkillRegistry::from_skills(skills);
463        let selected = reg.select(&["crm"], "anything").unwrap();
464        assert_eq!(selected.name, "high");
465    }
466
467    #[test]
468    fn registry_no_match() {
469        let skills =
470            vec![parse_skill("---\nname: a\ntriggers: [crm]\npriority: 1\n---\nA").unwrap()];
471        let reg = SkillRegistry::from_skills(skills);
472        assert!(reg.select(&["injection"], "test").is_none());
473    }
474
475    #[test]
476    fn registry_get_by_name() {
477        let skills = vec![
478            parse_skill("---\nname: alpha\ntriggers: [crm]\npriority: 1\n---\nA").unwrap(),
479            parse_skill("---\nname: beta\ntriggers: [crm]\npriority: 1\n---\nB").unwrap(),
480        ];
481        let reg = SkillRegistry::from_skills(skills);
482        assert_eq!(reg.get("beta").unwrap().body, "B");
483        assert!(reg.get("gamma").is_none());
484    }
485
486    #[test]
487    fn registry_list() {
488        let skills = vec![
489            parse_skill("---\nname: a\ndescription: Alpha\ntriggers: []\npriority: 1\n---\n")
490                .unwrap(),
491            parse_skill("---\nname: b\ndescription: Beta\ntriggers: []\npriority: 2\n---\n")
492                .unwrap(),
493        ];
494        let reg = SkillRegistry::from_skills(skills);
495        let list = reg.list();
496        assert_eq!(list.len(), 2);
497        // Sorted by priority desc
498        assert_eq!(list[0].0, "b");
499    }
500}