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(), 8, "Expected 8 built-in skills");
369 assert!(!registry.is_empty());
370
371 assert!(registry.get("agentic-search").is_some());
373 assert!(registry.get("code-search").is_some());
374 assert!(registry.get("code-review").is_some());
375 assert!(registry.get("explain-code").is_some());
376 assert!(registry.get("find-bugs").is_some());
377
378 assert!(registry.get("builtin-tools").is_some());
380 assert!(registry.get("delegate-task").is_some());
381 assert!(registry.get("find-skills").is_some());
382 }
383
384 #[test]
385 fn test_register_and_get() {
386 let registry = SkillRegistry::new();
387
388 let skill = Arc::new(Skill {
389 name: "test-skill".to_string(),
390 description: "A test skill".to_string(),
391 allowed_tools: None,
392 disable_model_invocation: false,
393 kind: SkillKind::Instruction,
394 content: "Test content".to_string(),
395 tags: vec![],
396 version: None,
397 });
398
399 registry.register(skill.clone()).unwrap();
400
401 assert_eq!(registry.len(), 1);
402 let retrieved = registry.get("test-skill").unwrap();
403 assert_eq!(retrieved.name, "test-skill");
404 }
405
406 #[test]
407 fn test_list() {
408 let registry = SkillRegistry::with_builtins();
409 let names = registry.list();
410
411 assert_eq!(names.len(), 8, "Expected 8 built-in skills");
412 assert!(names.contains(&"code-search".to_string()));
413 assert!(names.contains(&"code-review".to_string()));
414 assert!(names.contains(&"builtin-tools".to_string()));
415 assert!(names.contains(&"delegate-task".to_string()));
416 assert!(names.contains(&"find-skills".to_string()));
417 }
418
419 #[test]
420 fn test_remove() {
421 let registry = SkillRegistry::with_builtins();
422 assert_eq!(registry.len(), 8);
423
424 let removed = registry.remove("code-search");
425 assert!(removed.is_some());
426 assert_eq!(registry.len(), 7);
427 assert!(registry.get("code-search").is_none());
428 }
429
430 #[test]
431 fn test_clear() {
432 let registry = SkillRegistry::with_builtins();
433 assert_eq!(registry.len(), 8);
434
435 registry.clear();
436 assert_eq!(registry.len(), 0);
437 assert!(registry.is_empty());
438 }
439
440 #[test]
441 fn test_by_kind() {
442 let registry = SkillRegistry::with_builtins();
443 let instruction_skills = registry.by_kind(SkillKind::Instruction);
444
445 assert_eq!(
446 instruction_skills.len(),
447 8,
448 "Expected 8 instruction skills (5 code assistance + 3 tool documentation)"
449 );
450
451 let persona_skills = registry.by_kind(SkillKind::Persona);
452 assert_eq!(persona_skills.len(), 0);
453 }
454
455 #[test]
456 fn test_by_tag() {
457 let registry = SkillRegistry::with_builtins();
458 let search_skills = registry.by_tag("search");
459
460 assert_eq!(search_skills.len(), 2); let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
462 assert!(names.contains(&"code-search"));
463 assert!(names.contains(&"agentic-search"));
464
465 let security_skills = registry.by_tag("security");
466 assert_eq!(security_skills.len(), 1);
467 assert_eq!(security_skills[0].name, "find-bugs");
468 }
469
470 #[test]
471 fn test_load_from_dir() -> anyhow::Result<()> {
472 let temp_dir = TempDir::new()?;
473
474 let skill_path = temp_dir.path().join("test-skill.md");
476 let mut file = std::fs::File::create(&skill_path)?;
477 writeln!(file, "---")?;
478 writeln!(file, "name: test-skill")?;
479 writeln!(file, "description: A test skill")?;
480 writeln!(file, "kind: instruction")?;
481 writeln!(file, "---")?;
482 writeln!(file, "# Test Skill")?;
483 writeln!(file, "This is a test skill.")?;
484 drop(file);
485
486 let readme_path = temp_dir.path().join("README.md");
488 std::fs::write(&readme_path, "# README\nNot a skill")?;
489
490 let txt_path = temp_dir.path().join("notes.txt");
492 std::fs::write(&txt_path, "Some notes")?;
493
494 let registry = SkillRegistry::new();
495 let loaded = registry.load_from_dir(temp_dir.path())?;
496
497 assert_eq!(loaded, 1);
498 assert_eq!(registry.len(), 1);
499 assert!(registry.get("test-skill").is_some());
500
501 Ok(())
502 }
503
504 #[test]
505 fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
506 let temp_dir = TempDir::new()?;
507 let nested = temp_dir.path().join("nested").join("code-review-helper");
508 std::fs::create_dir_all(&nested)?;
509
510 let skill_path = nested.join("SKILL.md");
511 let mut file = std::fs::File::create(&skill_path)?;
512 writeln!(file, "---")?;
513 writeln!(file, "name: nested-skill")?;
514 writeln!(file, "description: A nested skill")?;
515 writeln!(file, "kind: instruction")?;
516 writeln!(file, "---")?;
517 writeln!(file, "# Nested Skill")?;
518 writeln!(file, "This skill lives in a nested SKILL.md.")?;
519 drop(file);
520
521 let registry = SkillRegistry::new();
522 let loaded = registry.load_from_dir(temp_dir.path())?;
523
524 assert_eq!(loaded, 1);
525 assert!(registry.get("nested-skill").is_some());
526 Ok(())
527 }
528
529 #[test]
530 fn test_load_from_file() -> anyhow::Result<()> {
531 let temp_dir = TempDir::new()?;
532 let skill_path = temp_dir.path().join("my-skill.md");
533
534 let mut file = std::fs::File::create(&skill_path)?;
535 writeln!(file, "---")?;
536 writeln!(file, "name: my-skill")?;
537 writeln!(file, "description: My custom skill")?;
538 writeln!(file, "---")?;
539 writeln!(file, "# My Skill")?;
540 drop(file);
541
542 let registry = SkillRegistry::new();
543 let skill = registry.load_from_file(&skill_path)?;
544
545 assert_eq!(skill.name, "my-skill");
546 assert_eq!(registry.len(), 1);
547
548 Ok(())
549 }
550
551 #[test]
552 fn test_to_system_prompt() {
553 let registry = SkillRegistry::with_builtins();
554 let prompt = registry.to_system_prompt();
555
556 assert!(prompt.contains("# Available Skills"));
557 assert!(prompt.contains("code-search"));
558 assert!(prompt.contains("code-review"));
559 assert!(prompt.contains("explain-code"));
560 assert!(prompt.contains("find-bugs"));
561 }
562
563 #[test]
564 fn test_load_from_nonexistent_dir() {
565 let registry = SkillRegistry::new();
566 let result = registry.load_from_dir("/nonexistent/path");
567
568 assert!(result.is_ok());
569 assert_eq!(result.unwrap(), 0);
570 }
571
572 #[test]
573 fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
574 let temp_dir = TempDir::new()?;
575 let path = temp_dir.path().join("not-a-directory.md");
576 std::fs::write(&path, "# not a directory")?;
577
578 let registry = SkillRegistry::new();
579 let err = registry.load_from_dir(&path).unwrap_err();
580 assert!(err.to_string().contains("Path is not a directory"));
581 Ok(())
582 }
583
584 #[test]
585 fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
586 let temp_dir = TempDir::new()?;
587
588 let first = temp_dir.path().join("first.md");
589 std::fs::write(
590 &first,
591 "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
592 )?;
593
594 let nested = temp_dir.path().join("nested");
595 std::fs::create_dir_all(&nested)?;
596 let second = nested.join("SKILL.md");
597 std::fs::write(
598 &second,
599 "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
600 )?;
601
602 let registry = SkillRegistry::new();
603 let loaded = registry.load_from_dir(temp_dir.path())?;
604
605 assert_eq!(loaded, 2);
606 assert_eq!(registry.len(), 1);
607 assert_eq!(
608 registry.get("duplicate-skill").unwrap().description,
609 "Second copy"
610 );
611 Ok(())
612 }
613
614 #[test]
617 fn test_register_with_validator_rejects_reserved() {
618 use crate::skills::validator::DefaultSkillValidator;
619
620 let registry = SkillRegistry::new();
621 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
622
623 let skill = Arc::new(Skill {
624 name: "code-search".to_string(), description: "Override builtin".to_string(),
626 allowed_tools: None,
627 disable_model_invocation: false,
628 kind: SkillKind::Instruction,
629 content: "Malicious override".to_string(),
630 tags: vec![],
631 version: None,
632 });
633
634 let result = registry.register(skill);
635 assert!(result.is_err());
636 assert_eq!(registry.len(), 0);
637 }
638
639 #[test]
640 fn test_register_with_validator_accepts_valid() {
641 use crate::skills::validator::DefaultSkillValidator;
642
643 let registry = SkillRegistry::new();
644 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
645
646 let skill = Arc::new(Skill {
647 name: "my-custom-skill".to_string(),
648 description: "A valid skill".to_string(),
649 allowed_tools: Some("read(*), grep(*)".to_string()),
650 disable_model_invocation: false,
651 kind: SkillKind::Instruction,
652 content: "Help with code review.".to_string(),
653 tags: vec![],
654 version: None,
655 });
656
657 assert!(registry.register(skill).is_ok());
658 assert_eq!(registry.len(), 1);
659 }
660
661 #[test]
662 fn test_register_without_validator_accepts_anything() {
663 let registry = SkillRegistry::new();
664 let skill = Arc::new(Skill {
667 name: "code-search".to_string(), description: "test".to_string(),
669 allowed_tools: None,
670 disable_model_invocation: false,
671 kind: SkillKind::Instruction,
672 content: "test".to_string(),
673 tags: vec![],
674 version: None,
675 });
676
677 assert!(registry.register(skill).is_ok());
678 }
679
680 #[test]
681 fn test_all_personas_and_scorer_accessor() {
682 let registry = SkillRegistry::new();
683 let scorer = Arc::new(DefaultSkillScorer::default());
684 registry.set_scorer(scorer.clone());
685
686 registry.register_unchecked(Arc::new(Skill {
687 name: "persona-skill".to_string(),
688 description: "Persona".to_string(),
689 allowed_tools: None,
690 disable_model_invocation: false,
691 kind: SkillKind::Persona,
692 content: "Persona content".to_string(),
693 tags: vec!["voice".to_string()],
694 version: None,
695 }));
696 registry.register_unchecked(Arc::new(Skill {
697 name: "instruction-skill".to_string(),
698 description: "Instruction".to_string(),
699 allowed_tools: None,
700 disable_model_invocation: false,
701 kind: SkillKind::Instruction,
702 content: "Instruction content".to_string(),
703 tags: vec!["workflow".to_string()],
704 version: None,
705 }));
706
707 assert_eq!(registry.all().len(), 2);
708 assert_eq!(registry.personas().len(), 1);
709 assert_eq!(registry.personas()[0].name, "persona-skill");
710 assert!(registry.scorer().is_some());
711 }
712
713 #[test]
714 fn test_load_from_file_with_validator_rejects() {
715 use crate::skills::validator::DefaultSkillValidator;
716
717 let temp_dir = TempDir::new().unwrap();
718 let skill_path = temp_dir.path().join("code-search.md");
719
720 let mut file = std::fs::File::create(&skill_path).unwrap();
721 writeln!(file, "---").unwrap();
722 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
724 writeln!(file, "---").unwrap();
725 writeln!(file, "# Override").unwrap();
726 drop(file);
727
728 let registry = SkillRegistry::new();
729 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
730
731 let result = registry.load_from_file(&skill_path);
732 assert!(result.is_err());
733 assert_eq!(registry.len(), 0);
734 }
735
736 #[test]
739 fn test_to_system_prompt_skips_disabled_skills() {
740 let registry = SkillRegistry::new();
741 let scorer = Arc::new(DefaultSkillScorer::default());
742 registry.set_scorer(scorer.clone());
743
744 registry.register_unchecked(Arc::new(Skill {
746 name: "good-skill".to_string(),
747 description: "Good".to_string(),
748 allowed_tools: None,
749 disable_model_invocation: false,
750 kind: SkillKind::Instruction,
751 content: "Good instructions".to_string(),
752 tags: vec![],
753 version: None,
754 }));
755 registry.register_unchecked(Arc::new(Skill {
756 name: "bad-skill".to_string(),
757 description: "Bad".to_string(),
758 allowed_tools: None,
759 disable_model_invocation: false,
760 kind: SkillKind::Instruction,
761 content: "Bad instructions".to_string(),
762 tags: vec![],
763 version: None,
764 }));
765
766 for _ in 0..5 {
768 scorer.record(SkillFeedback {
769 skill_name: "bad-skill".to_string(),
770 outcome: SkillOutcome::Failure,
771 score_delta: -1.0,
772 reason: "Did not help".to_string(),
773 timestamp: 0,
774 });
775 }
776
777 let prompt = registry.to_system_prompt();
778 assert!(prompt.contains("good-skill"));
779 assert!(!prompt.contains("bad-skill"));
780 }
781
782 #[test]
783 fn test_fork_is_independent() {
784 let original = SkillRegistry::with_builtins();
785 let fork = original.fork();
786
787 assert_eq!(fork.len(), original.len());
789
790 fork.register_unchecked(Arc::new(Skill {
792 name: "session-only".to_string(),
793 description: "Only in fork".to_string(),
794 allowed_tools: None,
795 disable_model_invocation: false,
796 kind: SkillKind::Instruction,
797 content: "content".to_string(),
798 tags: vec![],
799 version: None,
800 }));
801
802 assert_eq!(fork.len(), original.len() + 1);
803 assert!(fork.get("session-only").is_some());
804 assert!(original.get("session-only").is_none());
805 }
806
807 #[test]
808 fn test_fork_inherits_builtins() {
809 let fork = SkillRegistry::with_builtins().fork();
810 assert!(fork.get("code-search").is_some());
811 assert!(fork.get("code-review").is_some());
812 assert!(fork.get("find-bugs").is_some());
813 }
814
815 #[test]
816 fn test_fork_preserves_validator() {
817 use crate::skills::validator::DefaultSkillValidator;
818
819 let original = SkillRegistry::new();
820 original.set_validator(Arc::new(DefaultSkillValidator::default()));
821
822 let fork = original.fork();
823 let invalid = Arc::new(Skill {
824 name: "BadName".to_string(),
825 description: "invalid".to_string(),
826 allowed_tools: None,
827 disable_model_invocation: false,
828 kind: SkillKind::Instruction,
829 content: "content".to_string(),
830 tags: vec![],
831 version: None,
832 });
833
834 assert!(fork.register(invalid).is_err());
835 }
836
837 #[test]
838 fn test_fork_preserves_scorer() {
839 let original = SkillRegistry::new();
840 let scorer = Arc::new(DefaultSkillScorer::default());
841 original.set_scorer(scorer.clone());
842 original.register_unchecked(Arc::new(Skill {
843 name: "disabled-skill".to_string(),
844 description: "disabled".to_string(),
845 allowed_tools: None,
846 disable_model_invocation: false,
847 kind: SkillKind::Instruction,
848 content: "content".to_string(),
849 tags: vec![],
850 version: None,
851 }));
852
853 for _ in 0..5 {
854 scorer.record(SkillFeedback {
855 skill_name: "disabled-skill".to_string(),
856 outcome: SkillOutcome::Failure,
857 score_delta: -1.0,
858 reason: "bad".to_string(),
859 timestamp: 0,
860 });
861 }
862
863 let fork = original.fork();
864 let prompt = fork.to_system_prompt();
865 assert!(!prompt.contains("disabled-skill"));
866 }
867
868 #[test]
869 fn test_match_skills_matches_name_tag_and_description_and_skips_disabled() {
870 let registry = SkillRegistry::new();
871 let scorer = Arc::new(DefaultSkillScorer::default());
872 registry.set_scorer(scorer.clone());
873
874 registry.register_unchecked(Arc::new(Skill {
875 name: "build-planner".to_string(),
876 description: "Plan complex builds".to_string(),
877 allowed_tools: None,
878 disable_model_invocation: false,
879 kind: SkillKind::Instruction,
880 content: "Planner instructions".to_string(),
881 tags: vec!["architecture".to_string()],
882 version: None,
883 }));
884 registry.register_unchecked(Arc::new(Skill {
885 name: "silent-helper".to_string(),
886 description: "Troubleshoot quietly".to_string(),
887 allowed_tools: None,
888 disable_model_invocation: false,
889 kind: SkillKind::Instruction,
890 content: "Hidden instructions".to_string(),
891 tags: vec!["debug".to_string()],
892 version: None,
893 }));
894
895 for _ in 0..5 {
896 scorer.record(SkillFeedback {
897 skill_name: "silent-helper".to_string(),
898 outcome: SkillOutcome::Failure,
899 score_delta: -1.0,
900 reason: "disabled".to_string(),
901 timestamp: 0,
902 });
903 }
904
905 let by_name = registry.match_skills("please use build-planner for this task");
906 assert!(by_name.contains("Planner instructions"));
907
908 let by_tag = registry.match_skills("need architecture guidance");
909 assert!(by_tag.contains("Planner instructions"));
910
911 let by_description = registry.match_skills("help me plan the release");
912 assert!(by_description.contains("Planner instructions"));
913
914 let disabled = registry.match_skills("need debug help from silent-helper");
915 assert!(!disabled.contains("Hidden instructions"));
916
917 assert!(registry
918 .match_skills("totally unrelated request")
919 .is_empty());
920 }
921}