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` as a safety gate for externally loaded skills.
5
6use super::validator::SkillValidator;
7use super::{Skill, SkillKind};
8use anyhow::Context;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12
13/// Skill registry for managing available skills
14///
15/// Provides skill registration, loading from directories, and lookup by name.
16/// Optionally validates skills before registration.
17pub struct SkillRegistry {
18    skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
19    builtin_names: Arc<RwLock<HashSet<String>>>,
20    validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
21}
22
23impl SkillRegistry {
24    /// Create a new empty skill registry
25    pub fn new() -> Self {
26        Self {
27            skills: Arc::new(RwLock::new(HashMap::new())),
28            builtin_names: Arc::new(RwLock::new(HashSet::new())),
29            validator: Arc::new(RwLock::new(None)),
30        }
31    }
32
33    /// Create a registry with built-in skills.
34    ///
35    /// Built-in skills have been removed, so this is a compatibility alias for
36    /// [`Self::new`]. Load reusable skills through skill directories, inline
37    /// skills, or explicit registration.
38    pub fn with_builtins() -> Self {
39        let registry = Self::new();
40        for skill in super::builtin::builtin_skills() {
41            registry.register_builtin(skill);
42        }
43        registry
44    }
45
46    /// Fork this registry into an independent copy.
47    ///
48    /// The fork shares no state with the original — skills added to the fork
49    /// do not affect the source registry. The validator is preserved so
50    /// that session and delegated-agent registries keep the same safety policy.
51    pub fn fork(&self) -> Self {
52        let skills = self.skills.read().unwrap().clone();
53        let builtin_names = self.builtin_names.read().unwrap().clone();
54        Self {
55            skills: Arc::new(RwLock::new(skills)),
56            builtin_names: Arc::new(RwLock::new(builtin_names)),
57            validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
58        }
59    }
60
61    /// Set the skill validator (safety gate)
62    pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
63        *self.validator.write().unwrap() = Some(validator);
64    }
65
66    /// Register a skill with validation
67    ///
68    /// If a validator is set, the skill must pass validation before registration.
69    /// Returns an error if validation fails.
70    pub fn register(
71        &self,
72        skill: Arc<Skill>,
73    ) -> Result<(), super::validator::SkillValidationError> {
74        // Run validator if set
75        if let Some(ref validator) = *self.validator.read().unwrap() {
76            validator.validate(&skill)?;
77        }
78        self.register_unchecked(skill);
79        Ok(())
80    }
81
82    /// Register a skill without validation.
83    ///
84    /// If a future embedded skill set contains the same name, this replacement
85    /// is treated as external for global tool-restriction purposes.
86    pub fn register_unchecked(&self, skill: Arc<Skill>) {
87        let mut skills = self.skills.write().unwrap();
88        self.builtin_names.write().unwrap().remove(&skill.name);
89        skills.insert(skill.name.clone(), skill);
90    }
91
92    /// Register a validated, lifecycle-owned skill and return the registration
93    /// it shadowed.
94    ///
95    /// The lookup and replacement happen under one write lock so a live
96    /// session can later restore the exact prior skill by pointer identity.
97    /// Built-in skills remain protected from live replacement.
98    pub(crate) fn register_with_shadow(
99        &self,
100        skill: Arc<Skill>,
101    ) -> Result<(bool, Option<Arc<Skill>>), super::validator::SkillValidationError> {
102        if let Some(ref validator) = *self.validator.read().unwrap() {
103            validator.validate(&skill)?;
104        }
105
106        let name = skill.name.clone();
107        let mut skills = self.skills.write().unwrap();
108        let builtin_names = self.builtin_names.read().unwrap();
109        if builtin_names.contains(&name) {
110            tracing::warn!(
111                skill = %name,
112                "Rejected live skill registration because a built-in owns the name"
113            );
114            return Ok((false, None));
115        }
116
117        Ok((true, skills.insert(name, skill)))
118    }
119
120    /// Restore a shadowed skill only while `expected` still owns the name.
121    ///
122    /// This compare-and-replace prevents one lifecycle source from deleting or
123    /// overwriting a skill installed later by another source.
124    pub(crate) fn restore_if_same(
125        &self,
126        name: &str,
127        expected: &Arc<Skill>,
128        replacement: Option<Arc<Skill>>,
129    ) -> bool {
130        let mut skills = self.skills.write().unwrap();
131        let Some(current) = skills.get(name) else {
132            return false;
133        };
134        if !Arc::ptr_eq(current, expected) {
135            return false;
136        }
137
138        match replacement {
139            Some(skill) => {
140                skills.insert(name.to_string(), skill);
141            }
142            None => {
143                skills.remove(name);
144            }
145        }
146        true
147    }
148
149    fn register_builtin(&self, skill: Arc<Skill>) {
150        let name = skill.name.clone();
151        self.skills.write().unwrap().insert(name.clone(), skill);
152        self.builtin_names.write().unwrap().insert(name);
153    }
154
155    /// Get a skill by name
156    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
157        let skills = self.skills.read().unwrap();
158        skills.get(name).cloned()
159    }
160
161    /// List all registered skill names
162    pub fn list(&self) -> Vec<String> {
163        let skills = self.skills.read().unwrap();
164        let mut names = skills.keys().cloned().collect::<Vec<_>>();
165        names.sort();
166        names
167    }
168
169    /// Get all registered skills
170    pub fn all(&self) -> Vec<Arc<Skill>> {
171        let skills = self.skills.read().unwrap();
172        let mut values = skills.values().cloned().collect::<Vec<_>>();
173        values.sort_by(|a, b| a.name.cmp(&b.name));
174        values
175    }
176
177    /// Load skills from a directory
178    ///
179    /// Recursively scans the directory for skill files and attempts to parse them.
180    ///
181    /// Supported layouts:
182    /// - `path/to/skill.md`
183    /// - `path/to/skill/SKILL.md`
184    ///
185    /// Candidate files are processed in deterministic sorted order. Files that
186    /// fail to parse are skipped with debug logging; validation failures are
187    /// logged as warnings.
188    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
189        let dir = dir.as_ref();
190
191        if !dir.exists() {
192            return Ok(0);
193        }
194
195        if !dir.is_dir() {
196            anyhow::bail!("Path is not a directory: {}", dir.display());
197        }
198
199        let mut loaded = 0;
200        for candidate in Self::collect_skill_candidates(dir)? {
201            match Skill::from_file(&candidate) {
202                Ok(skill) => {
203                    let name = skill.name.clone();
204                    if skill.allowed_tools.is_none() {
205                        tracing::warn!(
206                            skill = %name,
207                            path = %candidate.display(),
208                            "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
209                        );
210                    } else if skill.uses_legacy_allowed_tools_syntax() {
211                        tracing::warn!(
212                            skill = %name,
213                            path = %candidate.display(),
214                            "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
215                        );
216                    }
217                    let skill = Arc::new(skill);
218                    if self.get(&name).is_some() {
219                        tracing::warn!(
220                            skill = %name,
221                            path = %candidate.display(),
222                            "Duplicate skill name encountered during directory load — overriding previous definition"
223                        );
224                    }
225                    match self.register(skill) {
226                        Ok(()) => loaded += 1,
227                        Err(e) => {
228                            tracing::warn!(
229                                "Skill validation failed for {}: {}",
230                                candidate.display(),
231                                e
232                            );
233                        }
234                    }
235                }
236                Err(e) => {
237                    tracing::debug!("Skipped {}: {}", candidate.display(), e);
238                }
239            }
240        }
241
242        Ok(loaded)
243    }
244
245    fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
246        fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
247            let mut entries = std::fs::read_dir(dir)
248                .with_context(|| format!("Failed to read directory: {}", dir.display()))?
249                .collect::<Result<Vec<_>, std::io::Error>>()?;
250            entries.sort_by_key(|entry| entry.path());
251
252            for entry in entries {
253                let path = entry.path();
254                if path.is_dir() {
255                    let skill_md = path.join("SKILL.md");
256                    if skill_md.is_file() {
257                        out.push(skill_md);
258                    }
259                    visit(&path, out)?;
260                } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
261                    out.push(path);
262                }
263            }
264            Ok(())
265        }
266
267        let mut out = Vec::new();
268        visit(dir, &mut out)?;
269        out.sort();
270        out.dedup();
271        Ok(out)
272    }
273
274    /// Load a single skill from a file
275    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
276        let skill = Skill::from_file(path)?;
277        let skill = Arc::new(skill);
278        self.register(skill.clone())
279            .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
280        Ok(skill)
281    }
282
283    /// Remove a skill by name
284    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
285        let mut skills = self.skills.write().unwrap();
286        skills.remove(name)
287    }
288
289    /// Clear all skills
290    pub fn clear(&self) {
291        let mut skills = self.skills.write().unwrap();
292        skills.clear();
293    }
294
295    /// Get the number of registered skills
296    pub fn len(&self) -> usize {
297        let skills = self.skills.read().unwrap();
298        skills.len()
299    }
300
301    /// Check if the registry is empty
302    pub fn is_empty(&self) -> bool {
303        self.len() == 0
304    }
305
306    /// Get all skills of a specific kind
307    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
308        let skills = self.skills.read().unwrap();
309        let mut values = skills
310            .values()
311            .filter(|s| s.kind == kind)
312            .cloned()
313            .collect::<Vec<_>>();
314        values.sort_by(|a, b| a.name.cmp(&b.name));
315        values
316    }
317
318    /// Instruction skills that actively constrain normal session tool use.
319    ///
320    /// Embedded skills, when present, can have local allowlists for explicit
321    /// `Skill` invocation, but those allowlists must not make the default
322    /// registry globally read-only. User-registered skills remain external.
323    pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
324        let skills = self.skills.read().unwrap();
325        let builtin_names = self.builtin_names.read().unwrap();
326        let mut values = skills
327            .values()
328            .filter(|skill| {
329                skill.kind == SkillKind::Instruction
330                    && skill.allowed_tools.is_some()
331                    && !builtin_names.contains(&skill.name)
332            })
333            .cloned()
334            .collect::<Vec<_>>();
335        values.sort_by(|a, b| a.name.cmp(&b.name));
336        values
337    }
338
339    /// Get all skills with a specific tag
340    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
341        let skills = self.skills.read().unwrap();
342        let mut values = skills
343            .values()
344            .filter(|s| s.tags.iter().any(|t| t == tag))
345            .cloned()
346            .collect::<Vec<_>>();
347        values.sort_by(|a, b| a.name.cmp(&b.name));
348        values
349    }
350
351    /// Get all persona-kind skills
352    ///
353    /// Personas are session-level system prompts bound at session creation.
354    /// They are NOT injected into the global system prompt via `to_system_prompt()`.
355    pub fn personas(&self) -> Vec<Arc<Skill>> {
356        self.by_kind(super::SkillKind::Persona)
357    }
358
359    /// Search discoverable instruction/tool skills by name, tag, description, or content.
360    pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
361        let skills = self.skills.read().unwrap();
362        let query_lower = query.to_lowercase();
363        let query_tokens: Vec<&str> = query_lower
364            .split_whitespace()
365            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
366            .filter(|w| w.len() >= 2)
367            .collect();
368
369        let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
370            .values()
371            .filter(|s| Self::is_discoverable_skill(s))
372            .filter_map(|skill| {
373                let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
374                if score == 0 {
375                    None
376                } else {
377                    Some((score, skill.name.clone(), Arc::clone(skill)))
378                }
379            })
380            .collect();
381
382        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
383        scored
384            .into_iter()
385            .take(limit.max(1))
386            .map(|(_, _, skill)| skill)
387            .collect()
388    }
389
390    fn is_discoverable_skill(skill: &Skill) -> bool {
391        skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
392    }
393
394    fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
395        if query_lower.trim().is_empty() {
396            return 1;
397        }
398
399        let name = skill.name.to_lowercase();
400        let description = skill.description.to_lowercase();
401        let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
402        let content = skill.content.to_lowercase();
403        let mut score = 0;
404
405        if query_lower.contains(&name) {
406            score += 100;
407        }
408        if tags.iter().any(|tag| query_lower.contains(tag)) {
409            score += 80;
410        }
411
412        for token in query_tokens {
413            if name.contains(token) {
414                score += 20;
415            }
416            if tags.iter().any(|tag| tag.contains(token)) {
417                score += 15;
418            }
419            if description.contains(token) {
420                score += 8;
421            }
422            if content.contains(token) {
423                score += 2;
424            }
425        }
426
427        score
428    }
429
430    /// Generate system prompt content from all instruction skills
431    ///
432    /// Concatenates the content of all instruction-type skills for injection
433    /// into the system prompt.
434    /// Persona-kind skills are excluded — they are bound per-session, not globally.
435    /// Generate the system prompt fragment for this registry.
436    ///
437    /// Only emits a skill directory (name + description) — NOT the full skill content.
438    /// Full content is injected on-demand via `match_skills` when a user request matches.
439    pub fn to_system_prompt(&self) -> String {
440        let skills = self.skills.read().unwrap();
441
442        let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
443
444        if !has_discoverable_skill {
445            return String::new();
446        }
447
448        String::from(crate::prompts::SKILLS_CATALOG_HEADER)
449    }
450
451    /// Return the full content of skills relevant to the given user input.
452    ///
453    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
454    /// Returns an empty string if no skills match — caller should not inject anything.
455    pub fn match_skills(&self, user_input: &str) -> String {
456        let matched = self.search(user_input, 3);
457
458        if matched.is_empty() {
459            return String::new();
460        }
461
462        let mut out = String::from("# Skill Instructions\n\n");
463        for skill in matched {
464            out.push_str(&skill.to_system_prompt());
465            out.push_str("\n\n---\n\n");
466        }
467        out
468    }
469}
470
471impl Default for SkillRegistry {
472    fn default() -> Self {
473        Self::new()
474    }
475}
476
477#[cfg(test)]
478#[path = "registry/tests.rs"]
479mod tests;