Skip to main content

a3s_code_core/skills/
registry.rs

1//! Skill Registry
2//!
3//! Manages skill registration, loading, and lookup.
4//! Integrates with `SkillValidator` (safety gate) and `SkillScorer` (feedback loop).
5
6use super::feedback::SkillScorer;
7use super::validator::SkillValidator;
8use super::Skill;
9use anyhow::Context;
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::{Arc, RwLock};
13
14/// Skill registry for managing available skills
15///
16/// Provides skill registration, loading from directories, and lookup by name.
17/// Optionally validates skills before registration and filters disabled skills
18/// from the system prompt based on feedback scores.
19pub struct SkillRegistry {
20    skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
21    validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
22    scorer: Arc<RwLock<Option<Arc<dyn SkillScorer>>>>,
23}
24
25impl SkillRegistry {
26    /// Create a new empty skill registry
27    pub fn new() -> Self {
28        Self {
29            skills: Arc::new(RwLock::new(HashMap::new())),
30            validator: Arc::new(RwLock::new(None)),
31            scorer: Arc::new(RwLock::new(None)),
32        }
33    }
34
35    /// Create a registry with built-in skills
36    pub fn with_builtins() -> Self {
37        let registry = Self::new();
38        for skill in super::builtin::builtin_skills() {
39            // Built-in skills bypass validation
40            registry.register_unchecked(skill);
41        }
42        registry
43    }
44
45    /// Set the skill validator (safety gate)
46    pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
47        *self.validator.write().unwrap() = Some(validator);
48    }
49
50    /// Set the skill scorer (feedback loop)
51    pub fn set_scorer(&self, scorer: Arc<dyn SkillScorer>) {
52        *self.scorer.write().unwrap() = Some(scorer);
53    }
54
55    /// Get the scorer (for external use, e.g., ManageSkillTool)
56    pub fn scorer(&self) -> Option<Arc<dyn SkillScorer>> {
57        self.scorer.read().unwrap().clone()
58    }
59
60    /// Register a skill with validation
61    ///
62    /// If a validator is set, the skill must pass validation before registration.
63    /// Returns an error if validation fails.
64    pub fn register(
65        &self,
66        skill: Arc<Skill>,
67    ) -> Result<(), super::validator::SkillValidationError> {
68        // Run validator if set
69        if let Some(ref validator) = *self.validator.read().unwrap() {
70            validator.validate(&skill)?;
71        }
72        self.register_unchecked(skill);
73        Ok(())
74    }
75
76    /// Register a skill without validation (for built-in skills)
77    pub fn register_unchecked(&self, skill: Arc<Skill>) {
78        let mut skills = self.skills.write().unwrap();
79        skills.insert(skill.name.clone(), skill);
80    }
81
82    /// Get a skill by name
83    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
84        let skills = self.skills.read().unwrap();
85        skills.get(name).cloned()
86    }
87
88    /// List all registered skill names
89    pub fn list(&self) -> Vec<String> {
90        let skills = self.skills.read().unwrap();
91        skills.keys().cloned().collect()
92    }
93
94    /// Get all registered skills
95    pub fn all(&self) -> Vec<Arc<Skill>> {
96        let skills = self.skills.read().unwrap();
97        skills.values().cloned().collect()
98    }
99
100    /// Load skills from a directory
101    ///
102    /// Scans the directory for `.md` files and attempts to parse them as skills.
103    /// Silently skips files that fail to parse.
104    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
105        let dir = dir.as_ref();
106
107        if !dir.exists() {
108            return Ok(0);
109        }
110
111        if !dir.is_dir() {
112            anyhow::bail!("Path is not a directory: {}", dir.display());
113        }
114
115        let mut loaded = 0;
116
117        for entry in std::fs::read_dir(dir)
118            .with_context(|| format!("Failed to read directory: {}", dir.display()))?
119        {
120            let entry = entry?;
121            let path = entry.path();
122
123            // Only process .md files
124            if path.extension().and_then(|s| s.to_str()) != Some("md") {
125                continue;
126            }
127
128            // Try to load the skill
129            match Skill::from_file(&path) {
130                Ok(skill) => {
131                    let skill = Arc::new(skill);
132                    match self.register(skill) {
133                        Ok(()) => loaded += 1,
134                        Err(e) => {
135                            tracing::warn!("Skill validation failed for {}: {}", path.display(), e);
136                        }
137                    }
138                }
139                Err(e) => {
140                    // Log but don't fail - some .md files might not be skills
141                    tracing::debug!("Skipped {}: {}", path.display(), e);
142                }
143            }
144        }
145
146        Ok(loaded)
147    }
148
149    /// Load a single skill from a file
150    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
151        let skill = Skill::from_file(path)?;
152        let skill = Arc::new(skill);
153        self.register(skill.clone())
154            .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
155        Ok(skill)
156    }
157
158    /// Remove a skill by name
159    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
160        let mut skills = self.skills.write().unwrap();
161        skills.remove(name)
162    }
163
164    /// Clear all skills
165    pub fn clear(&self) {
166        let mut skills = self.skills.write().unwrap();
167        skills.clear();
168    }
169
170    /// Get the number of registered skills
171    pub fn len(&self) -> usize {
172        let skills = self.skills.read().unwrap();
173        skills.len()
174    }
175
176    /// Check if the registry is empty
177    pub fn is_empty(&self) -> bool {
178        self.len() == 0
179    }
180
181    /// Get all skills of a specific kind
182    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
183        let skills = self.skills.read().unwrap();
184        skills
185            .values()
186            .filter(|s| s.kind == kind)
187            .cloned()
188            .collect()
189    }
190
191    /// Get all skills with a specific tag
192    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
193        let skills = self.skills.read().unwrap();
194        skills
195            .values()
196            .filter(|s| s.tags.iter().any(|t| t == tag))
197            .cloned()
198            .collect()
199    }
200
201    /// Get all persona-kind skills
202    ///
203    /// Personas are session-level system prompts bound at session creation.
204    /// They are NOT injected into the global system prompt via `to_system_prompt()`.
205    pub fn personas(&self) -> Vec<Arc<Skill>> {
206        self.by_kind(super::SkillKind::Persona)
207    }
208
209    /// Generate system prompt content from all instruction skills
210    ///
211    /// Concatenates the content of all instruction-type skills for injection
212    /// into the system prompt. Skills disabled by the scorer are excluded.
213    /// Persona-kind skills are excluded — they are bound per-session, not globally.
214    /// Generate the system prompt fragment for this registry.
215    ///
216    /// Only emits a skill directory (name + description) — NOT the full skill content.
217    /// Full content is injected on-demand via `match_skills` when a user request matches.
218    pub fn to_system_prompt(&self) -> String {
219        let skills = self.skills.read().unwrap();
220        let scorer = self.scorer.read().unwrap();
221
222        let instruction_skills: Vec<_> = skills
223            .values()
224            .filter(|s| s.kind == super::SkillKind::Instruction)
225            .filter(|s| match scorer.as_ref() {
226                Some(sc) => !sc.should_disable(&s.name),
227                None => true,
228            })
229            .collect();
230
231        if instruction_skills.is_empty() {
232            return String::new();
233        }
234
235        let mut prompt = String::from("# Available Skills\n\nThe following skills are available. Their full instructions will be provided when relevant.\n\n");
236        for skill in &instruction_skills {
237            prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
238        }
239        prompt
240    }
241
242    /// Return the full content of skills relevant to the given user input.
243    ///
244    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
245    /// Returns an empty string if no skills match — caller should not inject anything.
246    pub fn match_skills(&self, user_input: &str) -> String {
247        let skills = self.skills.read().unwrap();
248        let scorer = self.scorer.read().unwrap();
249        let input_lower = user_input.to_lowercase();
250
251        let matched: Vec<_> = skills
252            .values()
253            .filter(|s| s.kind == super::SkillKind::Instruction)
254            .filter(|s| match scorer.as_ref() {
255                Some(sc) => !sc.should_disable(&s.name),
256                None => true,
257            })
258            .filter(|s| {
259                // Match by skill name or any tag appearing in the user input
260                input_lower.contains(&s.name.to_lowercase())
261                    || s.tags
262                        .iter()
263                        .any(|t| input_lower.contains(&t.to_lowercase()))
264                    || input_lower.contains(
265                        s.description
266                            .to_lowercase()
267                            .split_whitespace()
268                            .next()
269                            .unwrap_or(""),
270                    )
271            })
272            .collect();
273
274        if matched.is_empty() {
275            return String::new();
276        }
277
278        let mut out = String::from("# Skill Instructions\n\n");
279        for skill in matched {
280            out.push_str(&skill.to_system_prompt());
281            out.push_str("\n\n---\n\n");
282        }
283        out
284    }
285}
286
287impl Default for SkillRegistry {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::skills::SkillKind;
297    use std::io::Write;
298    use tempfile::TempDir;
299
300    #[test]
301    fn test_new_registry() {
302        let registry = SkillRegistry::new();
303        assert_eq!(registry.len(), 0);
304        assert!(registry.is_empty());
305    }
306
307    #[test]
308    fn test_with_builtins() {
309        let registry = SkillRegistry::with_builtins();
310        assert_eq!(registry.len(), 7, "Expected 7 built-in skills");
311        assert!(!registry.is_empty());
312
313        // Code assistance skills
314        assert!(registry.get("code-search").is_some());
315        assert!(registry.get("code-review").is_some());
316        assert!(registry.get("explain-code").is_some());
317        assert!(registry.get("find-bugs").is_some());
318
319        // Tool documentation skills
320        assert!(registry.get("builtin-tools").is_some());
321        assert!(registry.get("delegate-task").is_some());
322        assert!(registry.get("find-skills").is_some());
323    }
324
325    #[test]
326    fn test_register_and_get() {
327        let registry = SkillRegistry::new();
328
329        let skill = Arc::new(Skill {
330            name: "test-skill".to_string(),
331            description: "A test skill".to_string(),
332            allowed_tools: None,
333            disable_model_invocation: false,
334            kind: SkillKind::Instruction,
335            content: "Test content".to_string(),
336            tags: vec![],
337            version: None,
338        });
339
340        registry.register(skill.clone()).unwrap();
341
342        assert_eq!(registry.len(), 1);
343        let retrieved = registry.get("test-skill").unwrap();
344        assert_eq!(retrieved.name, "test-skill");
345    }
346
347    #[test]
348    fn test_list() {
349        let registry = SkillRegistry::with_builtins();
350        let names = registry.list();
351
352        assert_eq!(names.len(), 7, "Expected 7 built-in skills");
353        assert!(names.contains(&"code-search".to_string()));
354        assert!(names.contains(&"code-review".to_string()));
355        assert!(names.contains(&"builtin-tools".to_string()));
356        assert!(names.contains(&"delegate-task".to_string()));
357        assert!(names.contains(&"find-skills".to_string()));
358    }
359
360    #[test]
361    fn test_remove() {
362        let registry = SkillRegistry::with_builtins();
363        assert_eq!(registry.len(), 7);
364
365        let removed = registry.remove("code-search");
366        assert!(removed.is_some());
367        assert_eq!(registry.len(), 6);
368        assert!(registry.get("code-search").is_none());
369    }
370
371    #[test]
372    fn test_clear() {
373        let registry = SkillRegistry::with_builtins();
374        assert_eq!(registry.len(), 7);
375
376        registry.clear();
377        assert_eq!(registry.len(), 0);
378        assert!(registry.is_empty());
379    }
380
381    #[test]
382    fn test_by_kind() {
383        let registry = SkillRegistry::with_builtins();
384        let instruction_skills = registry.by_kind(SkillKind::Instruction);
385
386        assert_eq!(
387            instruction_skills.len(),
388            7,
389            "Expected 7 instruction skills (4 code assistance + 3 tool documentation)"
390        );
391
392        let tool_skills = registry.by_kind(SkillKind::Tool);
393        assert_eq!(tool_skills.len(), 0);
394    }
395
396    #[test]
397    fn test_by_tag() {
398        let registry = SkillRegistry::with_builtins();
399        let search_skills = registry.by_tag("search");
400
401        assert_eq!(search_skills.len(), 1);
402        assert_eq!(search_skills[0].name, "code-search");
403
404        let security_skills = registry.by_tag("security");
405        assert_eq!(security_skills.len(), 1);
406        assert_eq!(security_skills[0].name, "find-bugs");
407    }
408
409    #[test]
410    fn test_load_from_dir() -> anyhow::Result<()> {
411        let temp_dir = TempDir::new()?;
412
413        // Create a valid skill file
414        let skill_path = temp_dir.path().join("test-skill.md");
415        let mut file = std::fs::File::create(&skill_path)?;
416        writeln!(file, "---")?;
417        writeln!(file, "name: test-skill")?;
418        writeln!(file, "description: A test skill")?;
419        writeln!(file, "kind: instruction")?;
420        writeln!(file, "---")?;
421        writeln!(file, "# Test Skill")?;
422        writeln!(file, "This is a test skill.")?;
423        drop(file);
424
425        // Create a non-skill .md file (should be skipped)
426        let readme_path = temp_dir.path().join("README.md");
427        std::fs::write(&readme_path, "# README\nNot a skill")?;
428
429        // Create a non-.md file (should be skipped)
430        let txt_path = temp_dir.path().join("notes.txt");
431        std::fs::write(&txt_path, "Some notes")?;
432
433        let registry = SkillRegistry::new();
434        let loaded = registry.load_from_dir(temp_dir.path())?;
435
436        assert_eq!(loaded, 1);
437        assert_eq!(registry.len(), 1);
438        assert!(registry.get("test-skill").is_some());
439
440        Ok(())
441    }
442
443    #[test]
444    fn test_load_from_file() -> anyhow::Result<()> {
445        let temp_dir = TempDir::new()?;
446        let skill_path = temp_dir.path().join("my-skill.md");
447
448        let mut file = std::fs::File::create(&skill_path)?;
449        writeln!(file, "---")?;
450        writeln!(file, "name: my-skill")?;
451        writeln!(file, "description: My custom skill")?;
452        writeln!(file, "---")?;
453        writeln!(file, "# My Skill")?;
454        drop(file);
455
456        let registry = SkillRegistry::new();
457        let skill = registry.load_from_file(&skill_path)?;
458
459        assert_eq!(skill.name, "my-skill");
460        assert_eq!(registry.len(), 1);
461
462        Ok(())
463    }
464
465    #[test]
466    fn test_to_system_prompt() {
467        let registry = SkillRegistry::with_builtins();
468        let prompt = registry.to_system_prompt();
469
470        assert!(prompt.contains("# Available Skills"));
471        assert!(prompt.contains("code-search"));
472        assert!(prompt.contains("code-review"));
473        assert!(prompt.contains("explain-code"));
474        assert!(prompt.contains("find-bugs"));
475    }
476
477    #[test]
478    fn test_load_from_nonexistent_dir() {
479        let registry = SkillRegistry::new();
480        let result = registry.load_from_dir("/nonexistent/path");
481
482        assert!(result.is_ok());
483        assert_eq!(result.unwrap(), 0);
484    }
485
486    // --- Validator integration ---
487
488    #[test]
489    fn test_register_with_validator_rejects_reserved() {
490        use crate::skills::validator::DefaultSkillValidator;
491
492        let registry = SkillRegistry::new();
493        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
494
495        let skill = Arc::new(Skill {
496            name: "code-search".to_string(), // reserved
497            description: "Override builtin".to_string(),
498            allowed_tools: None,
499            disable_model_invocation: false,
500            kind: SkillKind::Instruction,
501            content: "Malicious override".to_string(),
502            tags: vec![],
503            version: None,
504        });
505
506        let result = registry.register(skill);
507        assert!(result.is_err());
508        assert_eq!(registry.len(), 0);
509    }
510
511    #[test]
512    fn test_register_with_validator_accepts_valid() {
513        use crate::skills::validator::DefaultSkillValidator;
514
515        let registry = SkillRegistry::new();
516        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
517
518        let skill = Arc::new(Skill {
519            name: "my-custom-skill".to_string(),
520            description: "A valid skill".to_string(),
521            allowed_tools: Some("read(*), grep(*)".to_string()),
522            disable_model_invocation: false,
523            kind: SkillKind::Instruction,
524            content: "Help with code review.".to_string(),
525            tags: vec![],
526            version: None,
527        });
528
529        assert!(registry.register(skill).is_ok());
530        assert_eq!(registry.len(), 1);
531    }
532
533    #[test]
534    fn test_register_without_validator_accepts_anything() {
535        let registry = SkillRegistry::new();
536        // No validator set
537
538        let skill = Arc::new(Skill {
539            name: "code-search".to_string(), // reserved name, but no validator
540            description: "test".to_string(),
541            allowed_tools: None,
542            disable_model_invocation: false,
543            kind: SkillKind::Instruction,
544            content: "test".to_string(),
545            tags: vec![],
546            version: None,
547        });
548
549        assert!(registry.register(skill).is_ok());
550    }
551
552    #[test]
553    fn test_load_from_file_with_validator_rejects() {
554        use crate::skills::validator::DefaultSkillValidator;
555
556        let temp_dir = TempDir::new().unwrap();
557        let skill_path = temp_dir.path().join("code-search.md");
558
559        let mut file = std::fs::File::create(&skill_path).unwrap();
560        writeln!(file, "---").unwrap();
561        writeln!(file, "name: code-search").unwrap(); // reserved
562        writeln!(file, "description: Override").unwrap();
563        writeln!(file, "---").unwrap();
564        writeln!(file, "# Override").unwrap();
565        drop(file);
566
567        let registry = SkillRegistry::new();
568        registry.set_validator(Arc::new(DefaultSkillValidator::default()));
569
570        let result = registry.load_from_file(&skill_path);
571        assert!(result.is_err());
572        assert_eq!(registry.len(), 0);
573    }
574
575    // --- Scorer integration ---
576
577    #[test]
578    fn test_to_system_prompt_skips_disabled_skills() {
579        use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
580
581        let registry = SkillRegistry::new();
582        let scorer = Arc::new(DefaultSkillScorer::default());
583        registry.set_scorer(scorer.clone());
584
585        // Register two skills (unchecked to bypass validator)
586        registry.register_unchecked(Arc::new(Skill {
587            name: "good-skill".to_string(),
588            description: "Good".to_string(),
589            allowed_tools: None,
590            disable_model_invocation: false,
591            kind: SkillKind::Instruction,
592            content: "Good instructions".to_string(),
593            tags: vec![],
594            version: None,
595        }));
596        registry.register_unchecked(Arc::new(Skill {
597            name: "bad-skill".to_string(),
598            description: "Bad".to_string(),
599            allowed_tools: None,
600            disable_model_invocation: false,
601            kind: SkillKind::Instruction,
602            content: "Bad instructions".to_string(),
603            tags: vec![],
604            version: None,
605        }));
606
607        // Give bad-skill enough negative feedback to disable it
608        for _ in 0..5 {
609            scorer.record(SkillFeedback {
610                skill_name: "bad-skill".to_string(),
611                outcome: SkillOutcome::Failure,
612                score_delta: -1.0,
613                reason: "Did not help".to_string(),
614                timestamp: 0,
615            });
616        }
617
618        let prompt = registry.to_system_prompt();
619        assert!(prompt.contains("good-skill"));
620        assert!(!prompt.contains("bad-skill"));
621    }
622
623    #[test]
624    fn test_to_system_prompt_without_scorer_includes_all() {
625        let registry = SkillRegistry::new();
626        // No scorer set
627
628        registry.register_unchecked(Arc::new(Skill {
629            name: "skill-a".to_string(),
630            description: "A".to_string(),
631            allowed_tools: None,
632            disable_model_invocation: false,
633            kind: SkillKind::Instruction,
634            content: "Content A".to_string(),
635            tags: vec![],
636            version: None,
637        }));
638
639        let prompt = registry.to_system_prompt();
640        assert!(prompt.contains("skill-a"));
641    }
642}