1use super::feedback::SkillScorer;
7use super::validator::SkillValidator;
8use super::Skill;
9use anyhow::Context;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14pub 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 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 pub fn with_builtins() -> Self {
37 let registry = Self::new();
38 for skill in super::builtin::builtin_skills() {
39 registry.register_unchecked(skill);
41 }
42 registry
43 }
44
45 pub fn fork(&self) -> Self {
52 let skills = self.skills.read().unwrap().clone();
53 Self {
54 skills: Arc::new(RwLock::new(skills)),
55 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
56 scorer: Arc::new(RwLock::new(self.scorer.read().unwrap().clone())),
57 }
58 }
59
60 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
62 *self.validator.write().unwrap() = Some(validator);
63 }
64
65 pub fn set_scorer(&self, scorer: Arc<dyn SkillScorer>) {
67 *self.scorer.write().unwrap() = Some(scorer);
68 }
69
70 pub fn scorer(&self) -> Option<Arc<dyn SkillScorer>> {
72 self.scorer.read().unwrap().clone()
73 }
74
75 pub fn register(
80 &self,
81 skill: Arc<Skill>,
82 ) -> Result<(), super::validator::SkillValidationError> {
83 if let Some(ref validator) = *self.validator.read().unwrap() {
85 validator.validate(&skill)?;
86 }
87 self.register_unchecked(skill);
88 Ok(())
89 }
90
91 pub fn register_unchecked(&self, skill: Arc<Skill>) {
93 let mut skills = self.skills.write().unwrap();
94 skills.insert(skill.name.clone(), skill);
95 }
96
97 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
99 let skills = self.skills.read().unwrap();
100 skills.get(name).cloned()
101 }
102
103 pub fn list(&self) -> Vec<String> {
105 let skills = self.skills.read().unwrap();
106 skills.keys().cloned().collect()
107 }
108
109 pub fn all(&self) -> Vec<Arc<Skill>> {
111 let skills = self.skills.read().unwrap();
112 skills.values().cloned().collect()
113 }
114
115 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
127 let dir = dir.as_ref();
128
129 if !dir.exists() {
130 return Ok(0);
131 }
132
133 if !dir.is_dir() {
134 anyhow::bail!("Path is not a directory: {}", dir.display());
135 }
136
137 let mut loaded = 0;
138 for candidate in Self::collect_skill_candidates(dir)? {
139 match Skill::from_file(&candidate) {
140 Ok(skill) => {
141 let name = skill.name.clone();
142 let skill = Arc::new(skill);
143 if self.get(&name).is_some() {
144 tracing::warn!(
145 skill = %name,
146 path = %candidate.display(),
147 "Duplicate skill name encountered during directory load — overriding previous definition"
148 );
149 }
150 match self.register(skill) {
151 Ok(()) => loaded += 1,
152 Err(e) => {
153 tracing::warn!(
154 "Skill validation failed for {}: {}",
155 candidate.display(),
156 e
157 );
158 }
159 }
160 }
161 Err(e) => {
162 tracing::debug!("Skipped {}: {}", candidate.display(), e);
163 }
164 }
165 }
166
167 Ok(loaded)
168 }
169
170 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
171 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
172 let mut entries = std::fs::read_dir(dir)
173 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
174 .collect::<Result<Vec<_>, std::io::Error>>()?;
175 entries.sort_by_key(|entry| entry.path());
176
177 for entry in entries {
178 let path = entry.path();
179 if path.is_dir() {
180 let skill_md = path.join("SKILL.md");
181 if skill_md.is_file() {
182 out.push(skill_md);
183 }
184 visit(&path, out)?;
185 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
186 out.push(path);
187 }
188 }
189 Ok(())
190 }
191
192 let mut out = Vec::new();
193 visit(dir, &mut out)?;
194 out.sort();
195 out.dedup();
196 Ok(out)
197 }
198
199 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
201 let skill = Skill::from_file(path)?;
202 let skill = Arc::new(skill);
203 self.register(skill.clone())
204 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
205 Ok(skill)
206 }
207
208 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
210 let mut skills = self.skills.write().unwrap();
211 skills.remove(name)
212 }
213
214 pub fn clear(&self) {
216 let mut skills = self.skills.write().unwrap();
217 skills.clear();
218 }
219
220 pub fn len(&self) -> usize {
222 let skills = self.skills.read().unwrap();
223 skills.len()
224 }
225
226 pub fn is_empty(&self) -> bool {
228 self.len() == 0
229 }
230
231 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
233 let skills = self.skills.read().unwrap();
234 skills
235 .values()
236 .filter(|s| s.kind == kind)
237 .cloned()
238 .collect()
239 }
240
241 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
243 let skills = self.skills.read().unwrap();
244 skills
245 .values()
246 .filter(|s| s.tags.iter().any(|t| t == tag))
247 .cloned()
248 .collect()
249 }
250
251 pub fn personas(&self) -> Vec<Arc<Skill>> {
256 self.by_kind(super::SkillKind::Persona)
257 }
258
259 pub fn to_system_prompt(&self) -> String {
269 let skills = self.skills.read().unwrap();
270 let scorer = self.scorer.read().unwrap();
271
272 let instruction_skills: Vec<_> = skills
273 .values()
274 .filter(|s| {
275 s.kind == super::SkillKind::Instruction || s.kind == super::SkillKind::Tool
277 })
278 .filter(|s| match scorer.as_ref() {
279 Some(sc) => !sc.should_disable(&s.name),
280 None => true,
281 })
282 .collect();
283
284 if instruction_skills.is_empty() {
285 return String::new();
286 }
287
288 let mut prompt = String::from(crate::prompts::SKILLS_CATALOG_HEADER);
289 prompt.push_str("\n\n");
290 for skill in &instruction_skills {
291 prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
292 }
293 prompt
294 }
295
296 pub fn match_skills(&self, user_input: &str) -> String {
301 let skills = self.skills.read().unwrap();
302 let scorer = self.scorer.read().unwrap();
303 let input_lower = user_input.to_lowercase();
304
305 let matched: Vec<_> = skills
306 .values()
307 .filter(|s| {
308 s.kind == super::SkillKind::Instruction || s.kind == super::SkillKind::Tool
310 })
311 .filter(|s| match scorer.as_ref() {
312 Some(sc) => !sc.should_disable(&s.name),
313 None => true,
314 })
315 .filter(|s| {
316 input_lower.contains(&s.name.to_lowercase())
318 || s.tags
319 .iter()
320 .any(|t| input_lower.contains(&t.to_lowercase()))
321 || input_lower.contains(
322 s.description
323 .to_lowercase()
324 .split_whitespace()
325 .next()
326 .unwrap_or(""),
327 )
328 })
329 .collect();
330
331 if matched.is_empty() {
332 return String::new();
333 }
334
335 let mut out = String::from("# Skill Instructions\n\n");
336 for skill in matched {
337 out.push_str(&skill.to_system_prompt());
338 out.push_str("\n\n---\n\n");
339 }
340 out
341 }
342}
343
344impl Default for SkillRegistry {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
354 use crate::skills::SkillKind;
355 use std::io::Write;
356 use tempfile::TempDir;
357
358 #[test]
359 fn test_new_registry() {
360 let registry = SkillRegistry::new();
361 assert_eq!(registry.len(), 0);
362 assert!(registry.is_empty());
363 }
364
365 #[test]
366 fn test_with_builtins() {
367 let registry = SkillRegistry::with_builtins();
368 assert_eq!(registry.len(), 9, "Expected 9 built-in skills");
369 assert!(!registry.is_empty());
370
371 assert!(registry.get("agentic-search").is_some());
373 assert!(registry.get("agentic-parse").is_some());
374 assert!(registry.get("code-search").is_some());
375 assert!(registry.get("code-review").is_some());
376 assert!(registry.get("explain-code").is_some());
377 assert!(registry.get("find-bugs").is_some());
378
379 assert!(registry.get("builtin-tools").is_some());
381 assert!(registry.get("delegate-task").is_some());
382 assert!(registry.get("find-skills").is_some());
383 }
384
385 #[test]
386 fn test_register_and_get() {
387 let registry = SkillRegistry::new();
388
389 let skill = Arc::new(Skill {
390 name: "test-skill".to_string(),
391 description: "A test skill".to_string(),
392 allowed_tools: None,
393 disable_model_invocation: false,
394 kind: SkillKind::Instruction,
395 content: "Test content".to_string(),
396 tags: vec![],
397 version: None,
398 });
399
400 registry.register(skill.clone()).unwrap();
401
402 assert_eq!(registry.len(), 1);
403 let retrieved = registry.get("test-skill").unwrap();
404 assert_eq!(retrieved.name, "test-skill");
405 }
406
407 #[test]
408 fn test_list() {
409 let registry = SkillRegistry::with_builtins();
410 let names = registry.list();
411
412 assert_eq!(names.len(), 9, "Expected 9 built-in skills");
413 assert!(names.contains(&"code-search".to_string()));
414 assert!(names.contains(&"code-review".to_string()));
415 assert!(names.contains(&"builtin-tools".to_string()));
416 assert!(names.contains(&"delegate-task".to_string()));
417 assert!(names.contains(&"find-skills".to_string()));
418 }
419
420 #[test]
421 fn test_remove() {
422 let registry = SkillRegistry::with_builtins();
423 assert_eq!(registry.len(), 9);
424
425 let removed = registry.remove("code-search");
426 assert!(removed.is_some());
427 assert_eq!(registry.len(), 8);
428 assert!(registry.get("code-search").is_none());
429 }
430
431 #[test]
432 fn test_clear() {
433 let registry = SkillRegistry::with_builtins();
434 assert_eq!(registry.len(), 9);
435
436 registry.clear();
437 assert_eq!(registry.len(), 0);
438 assert!(registry.is_empty());
439 }
440
441 #[test]
442 fn test_by_kind() {
443 let registry = SkillRegistry::with_builtins();
444 let instruction_skills = registry.by_kind(SkillKind::Instruction);
445
446 assert_eq!(
447 instruction_skills.len(),
448 9,
449 "Expected 9 instruction skills (6 code assistance + 3 tool documentation)"
450 );
451
452 let persona_skills = registry.by_kind(SkillKind::Persona);
453 assert_eq!(persona_skills.len(), 0);
454 }
455
456 #[test]
457 fn test_by_tag() {
458 let registry = SkillRegistry::with_builtins();
459 let search_skills = registry.by_tag("search");
460
461 assert_eq!(search_skills.len(), 2); let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
463 assert!(names.contains(&"code-search"));
464 assert!(names.contains(&"agentic-search"));
465
466 let security_skills = registry.by_tag("security");
467 assert_eq!(security_skills.len(), 1);
468 assert_eq!(security_skills[0].name, "find-bugs");
469 }
470
471 #[test]
472 fn test_load_from_dir() -> anyhow::Result<()> {
473 let temp_dir = TempDir::new()?;
474
475 let skill_path = temp_dir.path().join("test-skill.md");
477 let mut file = std::fs::File::create(&skill_path)?;
478 writeln!(file, "---")?;
479 writeln!(file, "name: test-skill")?;
480 writeln!(file, "description: A test skill")?;
481 writeln!(file, "kind: instruction")?;
482 writeln!(file, "---")?;
483 writeln!(file, "# Test Skill")?;
484 writeln!(file, "This is a test skill.")?;
485 drop(file);
486
487 let readme_path = temp_dir.path().join("README.md");
489 std::fs::write(&readme_path, "# README\nNot a skill")?;
490
491 let txt_path = temp_dir.path().join("notes.txt");
493 std::fs::write(&txt_path, "Some notes")?;
494
495 let registry = SkillRegistry::new();
496 let loaded = registry.load_from_dir(temp_dir.path())?;
497
498 assert_eq!(loaded, 1);
499 assert_eq!(registry.len(), 1);
500 assert!(registry.get("test-skill").is_some());
501
502 Ok(())
503 }
504
505 #[test]
506 fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
507 let temp_dir = TempDir::new()?;
508 let nested = temp_dir.path().join("nested").join("code-review-helper");
509 std::fs::create_dir_all(&nested)?;
510
511 let skill_path = nested.join("SKILL.md");
512 let mut file = std::fs::File::create(&skill_path)?;
513 writeln!(file, "---")?;
514 writeln!(file, "name: nested-skill")?;
515 writeln!(file, "description: A nested skill")?;
516 writeln!(file, "kind: instruction")?;
517 writeln!(file, "---")?;
518 writeln!(file, "# Nested Skill")?;
519 writeln!(file, "This skill lives in a nested SKILL.md.")?;
520 drop(file);
521
522 let registry = SkillRegistry::new();
523 let loaded = registry.load_from_dir(temp_dir.path())?;
524
525 assert_eq!(loaded, 1);
526 assert!(registry.get("nested-skill").is_some());
527 Ok(())
528 }
529
530 #[test]
531 fn test_load_from_file() -> anyhow::Result<()> {
532 let temp_dir = TempDir::new()?;
533 let skill_path = temp_dir.path().join("my-skill.md");
534
535 let mut file = std::fs::File::create(&skill_path)?;
536 writeln!(file, "---")?;
537 writeln!(file, "name: my-skill")?;
538 writeln!(file, "description: My custom skill")?;
539 writeln!(file, "---")?;
540 writeln!(file, "# My Skill")?;
541 drop(file);
542
543 let registry = SkillRegistry::new();
544 let skill = registry.load_from_file(&skill_path)?;
545
546 assert_eq!(skill.name, "my-skill");
547 assert_eq!(registry.len(), 1);
548
549 Ok(())
550 }
551
552 #[test]
553 fn test_to_system_prompt() {
554 let registry = SkillRegistry::with_builtins();
555 let prompt = registry.to_system_prompt();
556
557 assert!(prompt.contains("# Available Skills"));
558 assert!(prompt.contains("code-search"));
559 assert!(prompt.contains("code-review"));
560 assert!(prompt.contains("explain-code"));
561 assert!(prompt.contains("find-bugs"));
562 }
563
564 #[test]
565 fn test_load_from_nonexistent_dir() {
566 let registry = SkillRegistry::new();
567 let result = registry.load_from_dir("/nonexistent/path");
568
569 assert!(result.is_ok());
570 assert_eq!(result.unwrap(), 0);
571 }
572
573 #[test]
574 fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
575 let temp_dir = TempDir::new()?;
576 let path = temp_dir.path().join("not-a-directory.md");
577 std::fs::write(&path, "# not a directory")?;
578
579 let registry = SkillRegistry::new();
580 let err = registry.load_from_dir(&path).unwrap_err();
581 assert!(err.to_string().contains("Path is not a directory"));
582 Ok(())
583 }
584
585 #[test]
586 fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
587 let temp_dir = TempDir::new()?;
588
589 let first = temp_dir.path().join("first.md");
590 std::fs::write(
591 &first,
592 "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
593 )?;
594
595 let nested = temp_dir.path().join("nested");
596 std::fs::create_dir_all(&nested)?;
597 let second = nested.join("SKILL.md");
598 std::fs::write(
599 &second,
600 "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
601 )?;
602
603 let registry = SkillRegistry::new();
604 let loaded = registry.load_from_dir(temp_dir.path())?;
605
606 assert_eq!(loaded, 2);
607 assert_eq!(registry.len(), 1);
608 assert_eq!(
609 registry.get("duplicate-skill").unwrap().description,
610 "Second copy"
611 );
612 Ok(())
613 }
614
615 #[test]
618 fn test_register_with_validator_rejects_reserved() {
619 use crate::skills::validator::DefaultSkillValidator;
620
621 let registry = SkillRegistry::new();
622 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
623
624 let skill = Arc::new(Skill {
625 name: "code-search".to_string(), description: "Override builtin".to_string(),
627 allowed_tools: None,
628 disable_model_invocation: false,
629 kind: SkillKind::Instruction,
630 content: "Malicious override".to_string(),
631 tags: vec![],
632 version: None,
633 });
634
635 let result = registry.register(skill);
636 assert!(result.is_err());
637 assert_eq!(registry.len(), 0);
638 }
639
640 #[test]
641 fn test_register_with_validator_accepts_valid() {
642 use crate::skills::validator::DefaultSkillValidator;
643
644 let registry = SkillRegistry::new();
645 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
646
647 let skill = Arc::new(Skill {
648 name: "my-custom-skill".to_string(),
649 description: "A valid skill".to_string(),
650 allowed_tools: Some("read(*), grep(*)".to_string()),
651 disable_model_invocation: false,
652 kind: SkillKind::Instruction,
653 content: "Help with code review.".to_string(),
654 tags: vec![],
655 version: None,
656 });
657
658 assert!(registry.register(skill).is_ok());
659 assert_eq!(registry.len(), 1);
660 }
661
662 #[test]
663 fn test_register_without_validator_accepts_anything() {
664 let registry = SkillRegistry::new();
665 let skill = Arc::new(Skill {
668 name: "code-search".to_string(), description: "test".to_string(),
670 allowed_tools: None,
671 disable_model_invocation: false,
672 kind: SkillKind::Instruction,
673 content: "test".to_string(),
674 tags: vec![],
675 version: None,
676 });
677
678 assert!(registry.register(skill).is_ok());
679 }
680
681 #[test]
682 fn test_all_personas_and_scorer_accessor() {
683 let registry = SkillRegistry::new();
684 let scorer = Arc::new(DefaultSkillScorer::default());
685 registry.set_scorer(scorer.clone());
686
687 registry.register_unchecked(Arc::new(Skill {
688 name: "persona-skill".to_string(),
689 description: "Persona".to_string(),
690 allowed_tools: None,
691 disable_model_invocation: false,
692 kind: SkillKind::Persona,
693 content: "Persona content".to_string(),
694 tags: vec!["voice".to_string()],
695 version: None,
696 }));
697 registry.register_unchecked(Arc::new(Skill {
698 name: "instruction-skill".to_string(),
699 description: "Instruction".to_string(),
700 allowed_tools: None,
701 disable_model_invocation: false,
702 kind: SkillKind::Instruction,
703 content: "Instruction content".to_string(),
704 tags: vec!["workflow".to_string()],
705 version: None,
706 }));
707
708 assert_eq!(registry.all().len(), 2);
709 assert_eq!(registry.personas().len(), 1);
710 assert_eq!(registry.personas()[0].name, "persona-skill");
711 assert!(registry.scorer().is_some());
712 }
713
714 #[test]
715 fn test_load_from_file_with_validator_rejects() {
716 use crate::skills::validator::DefaultSkillValidator;
717
718 let temp_dir = TempDir::new().unwrap();
719 let skill_path = temp_dir.path().join("code-search.md");
720
721 let mut file = std::fs::File::create(&skill_path).unwrap();
722 writeln!(file, "---").unwrap();
723 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
725 writeln!(file, "---").unwrap();
726 writeln!(file, "# Override").unwrap();
727 drop(file);
728
729 let registry = SkillRegistry::new();
730 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
731
732 let result = registry.load_from_file(&skill_path);
733 assert!(result.is_err());
734 assert_eq!(registry.len(), 0);
735 }
736
737 #[test]
740 fn test_to_system_prompt_skips_disabled_skills() {
741 let registry = SkillRegistry::new();
742 let scorer = Arc::new(DefaultSkillScorer::default());
743 registry.set_scorer(scorer.clone());
744
745 registry.register_unchecked(Arc::new(Skill {
747 name: "good-skill".to_string(),
748 description: "Good".to_string(),
749 allowed_tools: None,
750 disable_model_invocation: false,
751 kind: SkillKind::Instruction,
752 content: "Good instructions".to_string(),
753 tags: vec![],
754 version: None,
755 }));
756 registry.register_unchecked(Arc::new(Skill {
757 name: "bad-skill".to_string(),
758 description: "Bad".to_string(),
759 allowed_tools: None,
760 disable_model_invocation: false,
761 kind: SkillKind::Instruction,
762 content: "Bad instructions".to_string(),
763 tags: vec![],
764 version: None,
765 }));
766
767 for _ in 0..5 {
769 scorer.record(SkillFeedback {
770 skill_name: "bad-skill".to_string(),
771 outcome: SkillOutcome::Failure,
772 score_delta: -1.0,
773 reason: "Did not help".to_string(),
774 timestamp: 0,
775 });
776 }
777
778 let prompt = registry.to_system_prompt();
779 assert!(prompt.contains("good-skill"));
780 assert!(!prompt.contains("bad-skill"));
781 }
782
783 #[test]
784 fn test_fork_is_independent() {
785 let original = SkillRegistry::with_builtins();
786 let fork = original.fork();
787
788 assert_eq!(fork.len(), original.len());
790
791 fork.register_unchecked(Arc::new(Skill {
793 name: "session-only".to_string(),
794 description: "Only in fork".to_string(),
795 allowed_tools: None,
796 disable_model_invocation: false,
797 kind: SkillKind::Instruction,
798 content: "content".to_string(),
799 tags: vec![],
800 version: None,
801 }));
802
803 assert_eq!(fork.len(), original.len() + 1);
804 assert!(fork.get("session-only").is_some());
805 assert!(original.get("session-only").is_none());
806 }
807
808 #[test]
809 fn test_fork_inherits_builtins() {
810 let fork = SkillRegistry::with_builtins().fork();
811 assert!(fork.get("code-search").is_some());
812 assert!(fork.get("code-review").is_some());
813 assert!(fork.get("find-bugs").is_some());
814 }
815
816 #[test]
817 fn test_fork_preserves_validator() {
818 use crate::skills::validator::DefaultSkillValidator;
819
820 let original = SkillRegistry::new();
821 original.set_validator(Arc::new(DefaultSkillValidator::default()));
822
823 let fork = original.fork();
824 let invalid = Arc::new(Skill {
825 name: "BadName".to_string(),
826 description: "invalid".to_string(),
827 allowed_tools: None,
828 disable_model_invocation: false,
829 kind: SkillKind::Instruction,
830 content: "content".to_string(),
831 tags: vec![],
832 version: None,
833 });
834
835 assert!(fork.register(invalid).is_err());
836 }
837
838 #[test]
839 fn test_fork_preserves_scorer() {
840 let original = SkillRegistry::new();
841 let scorer = Arc::new(DefaultSkillScorer::default());
842 original.set_scorer(scorer.clone());
843 original.register_unchecked(Arc::new(Skill {
844 name: "disabled-skill".to_string(),
845 description: "disabled".to_string(),
846 allowed_tools: None,
847 disable_model_invocation: false,
848 kind: SkillKind::Instruction,
849 content: "content".to_string(),
850 tags: vec![],
851 version: None,
852 }));
853
854 for _ in 0..5 {
855 scorer.record(SkillFeedback {
856 skill_name: "disabled-skill".to_string(),
857 outcome: SkillOutcome::Failure,
858 score_delta: -1.0,
859 reason: "bad".to_string(),
860 timestamp: 0,
861 });
862 }
863
864 let fork = original.fork();
865 let prompt = fork.to_system_prompt();
866 assert!(!prompt.contains("disabled-skill"));
867 }
868
869 #[test]
870 fn test_match_skills_matches_name_tag_and_description_and_skips_disabled() {
871 let registry = SkillRegistry::new();
872 let scorer = Arc::new(DefaultSkillScorer::default());
873 registry.set_scorer(scorer.clone());
874
875 registry.register_unchecked(Arc::new(Skill {
876 name: "build-planner".to_string(),
877 description: "Plan complex builds".to_string(),
878 allowed_tools: None,
879 disable_model_invocation: false,
880 kind: SkillKind::Instruction,
881 content: "Planner instructions".to_string(),
882 tags: vec!["architecture".to_string()],
883 version: None,
884 }));
885 registry.register_unchecked(Arc::new(Skill {
886 name: "silent-helper".to_string(),
887 description: "Troubleshoot quietly".to_string(),
888 allowed_tools: None,
889 disable_model_invocation: false,
890 kind: SkillKind::Instruction,
891 content: "Hidden instructions".to_string(),
892 tags: vec!["debug".to_string()],
893 version: None,
894 }));
895
896 for _ in 0..5 {
897 scorer.record(SkillFeedback {
898 skill_name: "silent-helper".to_string(),
899 outcome: SkillOutcome::Failure,
900 score_delta: -1.0,
901 reason: "disabled".to_string(),
902 timestamp: 0,
903 });
904 }
905
906 let by_name = registry.match_skills("please use build-planner for this task");
907 assert!(by_name.contains("Planner instructions"));
908
909 let by_tag = registry.match_skills("need architecture guidance");
910 assert!(by_tag.contains("Planner instructions"));
911
912 let by_description = registry.match_skills("help me plan the release");
913 assert!(by_description.contains("Planner instructions"));
914
915 let disabled = registry.match_skills("need debug help from silent-helper");
916 assert!(!disabled.contains("Hidden instructions"));
917
918 assert!(registry
919 .match_skills("totally unrelated request")
920 .is_empty());
921 }
922}