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