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        self.builtin_names.write().unwrap().remove(&skill.name);
88        let mut skills = self.skills.write().unwrap();
89        skills.insert(skill.name.clone(), skill);
90    }
91
92    fn register_builtin(&self, skill: Arc<Skill>) {
93        let name = skill.name.clone();
94        self.skills.write().unwrap().insert(name.clone(), skill);
95        self.builtin_names.write().unwrap().insert(name);
96    }
97
98    /// Get a skill by name
99    pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
100        let skills = self.skills.read().unwrap();
101        skills.get(name).cloned()
102    }
103
104    /// List all registered skill names
105    pub fn list(&self) -> Vec<String> {
106        let skills = self.skills.read().unwrap();
107        let mut names = skills.keys().cloned().collect::<Vec<_>>();
108        names.sort();
109        names
110    }
111
112    /// Get all registered skills
113    pub fn all(&self) -> Vec<Arc<Skill>> {
114        let skills = self.skills.read().unwrap();
115        let mut values = skills.values().cloned().collect::<Vec<_>>();
116        values.sort_by(|a, b| a.name.cmp(&b.name));
117        values
118    }
119
120    /// Load skills from a directory
121    ///
122    /// Recursively scans the directory for skill files and attempts to parse them.
123    ///
124    /// Supported layouts:
125    /// - `path/to/skill.md`
126    /// - `path/to/skill/SKILL.md`
127    ///
128    /// Candidate files are processed in deterministic sorted order. Files that
129    /// fail to parse are skipped with debug logging; validation failures are
130    /// logged as warnings.
131    pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
132        let dir = dir.as_ref();
133
134        if !dir.exists() {
135            return Ok(0);
136        }
137
138        if !dir.is_dir() {
139            anyhow::bail!("Path is not a directory: {}", dir.display());
140        }
141
142        let mut loaded = 0;
143        for candidate in Self::collect_skill_candidates(dir)? {
144            match Skill::from_file(&candidate) {
145                Ok(skill) => {
146                    let name = skill.name.clone();
147                    if skill.allowed_tools.is_none() {
148                        tracing::warn!(
149                            skill = %name,
150                            path = %candidate.display(),
151                            "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
152                        );
153                    } else if skill.uses_legacy_allowed_tools_syntax() {
154                        tracing::warn!(
155                            skill = %name,
156                            path = %candidate.display(),
157                            "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
158                        );
159                    }
160                    let skill = Arc::new(skill);
161                    if self.get(&name).is_some() {
162                        tracing::warn!(
163                            skill = %name,
164                            path = %candidate.display(),
165                            "Duplicate skill name encountered during directory load — overriding previous definition"
166                        );
167                    }
168                    match self.register(skill) {
169                        Ok(()) => loaded += 1,
170                        Err(e) => {
171                            tracing::warn!(
172                                "Skill validation failed for {}: {}",
173                                candidate.display(),
174                                e
175                            );
176                        }
177                    }
178                }
179                Err(e) => {
180                    tracing::debug!("Skipped {}: {}", candidate.display(), e);
181                }
182            }
183        }
184
185        Ok(loaded)
186    }
187
188    fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
189        fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
190            let mut entries = std::fs::read_dir(dir)
191                .with_context(|| format!("Failed to read directory: {}", dir.display()))?
192                .collect::<Result<Vec<_>, std::io::Error>>()?;
193            entries.sort_by_key(|entry| entry.path());
194
195            for entry in entries {
196                let path = entry.path();
197                if path.is_dir() {
198                    let skill_md = path.join("SKILL.md");
199                    if skill_md.is_file() {
200                        out.push(skill_md);
201                    }
202                    visit(&path, out)?;
203                } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
204                    out.push(path);
205                }
206            }
207            Ok(())
208        }
209
210        let mut out = Vec::new();
211        visit(dir, &mut out)?;
212        out.sort();
213        out.dedup();
214        Ok(out)
215    }
216
217    /// Load a single skill from a file
218    pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
219        let skill = Skill::from_file(path)?;
220        let skill = Arc::new(skill);
221        self.register(skill.clone())
222            .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
223        Ok(skill)
224    }
225
226    /// Remove a skill by name
227    pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
228        let mut skills = self.skills.write().unwrap();
229        skills.remove(name)
230    }
231
232    /// Clear all skills
233    pub fn clear(&self) {
234        let mut skills = self.skills.write().unwrap();
235        skills.clear();
236    }
237
238    /// Get the number of registered skills
239    pub fn len(&self) -> usize {
240        let skills = self.skills.read().unwrap();
241        skills.len()
242    }
243
244    /// Check if the registry is empty
245    pub fn is_empty(&self) -> bool {
246        self.len() == 0
247    }
248
249    /// Get all skills of a specific kind
250    pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
251        let skills = self.skills.read().unwrap();
252        let mut values = skills
253            .values()
254            .filter(|s| s.kind == kind)
255            .cloned()
256            .collect::<Vec<_>>();
257        values.sort_by(|a, b| a.name.cmp(&b.name));
258        values
259    }
260
261    /// Instruction skills that actively constrain normal session tool use.
262    ///
263    /// Embedded skills, when present, can have local allowlists for explicit
264    /// `Skill` invocation, but those allowlists must not make the default
265    /// registry globally read-only. User-registered skills remain external.
266    pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
267        let skills = self.skills.read().unwrap();
268        let builtin_names = self.builtin_names.read().unwrap();
269        let mut values = skills
270            .values()
271            .filter(|skill| {
272                skill.kind == SkillKind::Instruction
273                    && skill.allowed_tools.is_some()
274                    && !builtin_names.contains(&skill.name)
275            })
276            .cloned()
277            .collect::<Vec<_>>();
278        values.sort_by(|a, b| a.name.cmp(&b.name));
279        values
280    }
281
282    /// Get all skills with a specific tag
283    pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
284        let skills = self.skills.read().unwrap();
285        let mut values = skills
286            .values()
287            .filter(|s| s.tags.iter().any(|t| t == tag))
288            .cloned()
289            .collect::<Vec<_>>();
290        values.sort_by(|a, b| a.name.cmp(&b.name));
291        values
292    }
293
294    /// Get all persona-kind skills
295    ///
296    /// Personas are session-level system prompts bound at session creation.
297    /// They are NOT injected into the global system prompt via `to_system_prompt()`.
298    pub fn personas(&self) -> Vec<Arc<Skill>> {
299        self.by_kind(super::SkillKind::Persona)
300    }
301
302    /// Search discoverable instruction/tool skills by name, tag, description, or content.
303    pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
304        let skills = self.skills.read().unwrap();
305        let query_lower = query.to_lowercase();
306        let query_tokens: Vec<&str> = query_lower
307            .split_whitespace()
308            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
309            .filter(|w| w.len() >= 2)
310            .collect();
311
312        let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
313            .values()
314            .filter(|s| Self::is_discoverable_skill(s))
315            .filter_map(|skill| {
316                let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
317                if score == 0 {
318                    None
319                } else {
320                    Some((score, skill.name.clone(), Arc::clone(skill)))
321                }
322            })
323            .collect();
324
325        scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
326        scored
327            .into_iter()
328            .take(limit.max(1))
329            .map(|(_, _, skill)| skill)
330            .collect()
331    }
332
333    fn is_discoverable_skill(skill: &Skill) -> bool {
334        skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
335    }
336
337    fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
338        if query_lower.trim().is_empty() {
339            return 1;
340        }
341
342        let name = skill.name.to_lowercase();
343        let description = skill.description.to_lowercase();
344        let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
345        let content = skill.content.to_lowercase();
346        let mut score = 0;
347
348        if query_lower.contains(&name) {
349            score += 100;
350        }
351        if tags.iter().any(|tag| query_lower.contains(tag)) {
352            score += 80;
353        }
354
355        for token in query_tokens {
356            if name.contains(token) {
357                score += 20;
358            }
359            if tags.iter().any(|tag| tag.contains(token)) {
360                score += 15;
361            }
362            if description.contains(token) {
363                score += 8;
364            }
365            if content.contains(token) {
366                score += 2;
367            }
368        }
369
370        score
371    }
372
373    /// Generate system prompt content from all instruction skills
374    ///
375    /// Concatenates the content of all instruction-type skills for injection
376    /// into the system prompt.
377    /// Persona-kind skills are excluded — they are bound per-session, not globally.
378    /// Generate the system prompt fragment for this registry.
379    ///
380    /// Only emits a skill directory (name + description) — NOT the full skill content.
381    /// Full content is injected on-demand via `match_skills` when a user request matches.
382    pub fn to_system_prompt(&self) -> String {
383        let skills = self.skills.read().unwrap();
384
385        let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
386
387        if !has_discoverable_skill {
388            return String::new();
389        }
390
391        String::from(crate::prompts::SKILLS_CATALOG_HEADER)
392    }
393
394    /// Return the full content of skills relevant to the given user input.
395    ///
396    /// Matches by checking if any skill name or tag appears in the input (case-insensitive).
397    /// Returns an empty string if no skills match — caller should not inject anything.
398    pub fn match_skills(&self, user_input: &str) -> String {
399        let matched = self.search(user_input, 3);
400
401        if matched.is_empty() {
402            return String::new();
403        }
404
405        let mut out = String::from("# Skill Instructions\n\n");
406        for skill in matched {
407            out.push_str(&skill.to_system_prompt());
408            out.push_str("\n\n---\n\n");
409        }
410        out
411    }
412}
413
414impl Default for SkillRegistry {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420#[cfg(test)]
421#[path = "registry/tests.rs"]
422mod tests;