Skip to main content

agent_core_runtime/skills/
registry.rs

1//! Thread-safe registry for discovered skills.
2
3use crate::skills::types::Skill;
4use std::collections::HashMap;
5use std::sync::RwLock;
6
7/// Thread-safe registry that stores discovered skills.
8#[derive(Debug, Default)]
9pub struct SkillRegistry {
10    skills: RwLock<HashMap<String, Skill>>,
11}
12
13impl SkillRegistry {
14    /// Create a new empty skill registry.
15    pub fn new() -> Self {
16        Self::default()
17    }
18
19    /// Register a skill in the registry.
20    ///
21    /// If a skill with the same name already exists, it will be replaced.
22    pub fn register(&self, skill: Skill) -> Option<Skill> {
23        let mut skills = self.skills.write().unwrap();
24        skills.insert(skill.metadata.name.clone(), skill)
25    }
26
27    /// Unregister a skill by name.
28    ///
29    /// Returns the removed skill if it existed.
30    pub fn unregister(&self, name: &str) -> Option<Skill> {
31        let mut skills = self.skills.write().unwrap();
32        skills.remove(name)
33    }
34
35    /// Get a skill by name.
36    pub fn get(&self, name: &str) -> Option<Skill> {
37        let skills = self.skills.read().unwrap();
38        skills.get(name).cloned()
39    }
40
41    /// List all registered skills.
42    pub fn list(&self) -> Vec<Skill> {
43        let skills = self.skills.read().unwrap();
44        skills.values().cloned().collect()
45    }
46
47    /// Get the names of all registered skills.
48    pub fn names(&self) -> Vec<String> {
49        let skills = self.skills.read().unwrap();
50        skills.keys().cloned().collect()
51    }
52
53    /// Check if a skill is registered.
54    pub fn contains(&self, name: &str) -> bool {
55        let skills = self.skills.read().unwrap();
56        skills.contains_key(name)
57    }
58
59    /// Get the number of registered skills.
60    pub fn len(&self) -> usize {
61        let skills = self.skills.read().unwrap();
62        skills.len()
63    }
64
65    /// Check if the registry is empty.
66    pub fn is_empty(&self) -> bool {
67        self.len() == 0
68    }
69
70    /// Clear all skills from the registry.
71    pub fn clear(&self) {
72        let mut skills = self.skills.write().unwrap();
73        skills.clear();
74    }
75
76    /// Generate XML for injection into system prompts.
77    ///
78    /// The XML format follows the Agent Skills specification:
79    /// ```xml
80    /// <available_skills>
81    ///   <skill>
82    ///     <name>skill-name</name>
83    ///     <description>Skill description.</description>
84    ///     <location>/path/to/SKILL.md</location>
85    ///   </skill>
86    /// </available_skills>
87    /// ```
88    pub fn to_prompt_xml(&self) -> String {
89        let skills = self.skills.read().unwrap();
90
91        if skills.is_empty() {
92            return String::new();
93        }
94
95        let mut xml = String::from("<available_skills>\n");
96
97        // Sort by name for consistent output
98        let mut sorted_skills: Vec<_> = skills.values().collect();
99        sorted_skills.sort_by(|a, b| a.metadata.name.cmp(&b.metadata.name));
100
101        for skill in sorted_skills {
102            xml.push_str("  <skill>\n");
103            xml.push_str(&format!("    <name>{}</name>\n", escape_xml(&skill.metadata.name)));
104            xml.push_str(&format!(
105                "    <description>{}</description>\n",
106                escape_xml(&skill.metadata.description)
107            ));
108            xml.push_str(&format!(
109                "    <location>{}</location>\n",
110                escape_xml(&skill.skill_md_path.display().to_string())
111            ));
112            xml.push_str("  </skill>\n");
113        }
114
115        xml.push_str("</available_skills>");
116        xml
117    }
118}
119
120/// Escape special XML characters.
121fn escape_xml(s: &str) -> String {
122    s.replace('&', "&amp;")
123        .replace('<', "&lt;")
124        .replace('>', "&gt;")
125        .replace('"', "&quot;")
126        .replace('\'', "&apos;")
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::skills::types::SkillMetadata;
133    use std::path::PathBuf;
134
135    fn create_test_skill(name: &str, description: &str) -> Skill {
136        Skill {
137            metadata: SkillMetadata {
138                name: name.to_string(),
139                description: description.to_string(),
140                license: None,
141                compatibility: None,
142                metadata: None,
143                allowed_tools: None,
144            },
145            path: PathBuf::from(format!("/skills/{}", name)),
146            skill_md_path: PathBuf::from(format!("/skills/{}/SKILL.md", name)),
147        }
148    }
149
150    #[test]
151    fn test_register_and_get() {
152        let registry = SkillRegistry::new();
153        let skill = create_test_skill("test-skill", "A test skill");
154
155        assert!(registry.register(skill).is_none());
156
157        let retrieved = registry.get("test-skill").unwrap();
158        assert_eq!(retrieved.metadata.name, "test-skill");
159        assert_eq!(retrieved.metadata.description, "A test skill");
160    }
161
162    #[test]
163    fn test_register_replaces_existing() {
164        let registry = SkillRegistry::new();
165
166        let skill1 = create_test_skill("my-skill", "First version");
167        let skill2 = create_test_skill("my-skill", "Second version");
168
169        registry.register(skill1);
170        let replaced = registry.register(skill2);
171
172        assert!(replaced.is_some());
173        assert_eq!(replaced.unwrap().metadata.description, "First version");
174
175        let current = registry.get("my-skill").unwrap();
176        assert_eq!(current.metadata.description, "Second version");
177    }
178
179    #[test]
180    fn test_unregister() {
181        let registry = SkillRegistry::new();
182        let skill = create_test_skill("to-remove", "Will be removed");
183
184        registry.register(skill);
185        assert!(registry.contains("to-remove"));
186
187        let removed = registry.unregister("to-remove");
188        assert!(removed.is_some());
189        assert!(!registry.contains("to-remove"));
190
191        // Unregister nonexistent returns None
192        assert!(registry.unregister("nonexistent").is_none());
193    }
194
195    #[test]
196    fn test_list() {
197        let registry = SkillRegistry::new();
198
199        registry.register(create_test_skill("skill-a", "A"));
200        registry.register(create_test_skill("skill-b", "B"));
201        registry.register(create_test_skill("skill-c", "C"));
202
203        let skills = registry.list();
204        assert_eq!(skills.len(), 3);
205
206        let names: Vec<_> = skills.iter().map(|s| s.metadata.name.as_str()).collect();
207        assert!(names.contains(&"skill-a"));
208        assert!(names.contains(&"skill-b"));
209        assert!(names.contains(&"skill-c"));
210    }
211
212    #[test]
213    fn test_names() {
214        let registry = SkillRegistry::new();
215
216        registry.register(create_test_skill("alpha", "A"));
217        registry.register(create_test_skill("beta", "B"));
218
219        let names = registry.names();
220        assert_eq!(names.len(), 2);
221        assert!(names.contains(&"alpha".to_string()));
222        assert!(names.contains(&"beta".to_string()));
223    }
224
225    #[test]
226    fn test_len_and_is_empty() {
227        let registry = SkillRegistry::new();
228
229        assert!(registry.is_empty());
230        assert_eq!(registry.len(), 0);
231
232        registry.register(create_test_skill("one", "1"));
233        assert!(!registry.is_empty());
234        assert_eq!(registry.len(), 1);
235
236        registry.register(create_test_skill("two", "2"));
237        assert_eq!(registry.len(), 2);
238    }
239
240    #[test]
241    fn test_clear() {
242        let registry = SkillRegistry::new();
243
244        registry.register(create_test_skill("a", "A"));
245        registry.register(create_test_skill("b", "B"));
246        assert_eq!(registry.len(), 2);
247
248        registry.clear();
249        assert!(registry.is_empty());
250    }
251
252    #[test]
253    fn test_to_prompt_xml_empty() {
254        let registry = SkillRegistry::new();
255        let xml = registry.to_prompt_xml();
256        assert!(xml.is_empty());
257    }
258
259    #[test]
260    fn test_to_prompt_xml() {
261        let registry = SkillRegistry::new();
262
263        registry.register(create_test_skill("pdf-tools", "Extract text from PDFs"));
264        registry.register(create_test_skill("git-helper", "Git operations helper"));
265
266        let xml = registry.to_prompt_xml();
267
268        assert!(xml.starts_with("<available_skills>"));
269        assert!(xml.ends_with("</available_skills>"));
270        assert!(xml.contains("<name>git-helper</name>"));
271        assert!(xml.contains("<name>pdf-tools</name>"));
272        assert!(xml.contains("<description>Extract text from PDFs</description>"));
273        assert!(xml.contains("<location>/skills/git-helper/SKILL.md</location>"));
274    }
275
276    #[test]
277    fn test_to_prompt_xml_escapes_special_chars() {
278        let registry = SkillRegistry::new();
279
280        registry.register(create_test_skill("test", "Uses <xml> & \"quotes\""));
281
282        let xml = registry.to_prompt_xml();
283
284        assert!(xml.contains("&lt;xml&gt;"));
285        assert!(xml.contains("&amp;"));
286        assert!(xml.contains("&quot;quotes&quot;"));
287    }
288
289    #[test]
290    fn test_thread_safety() {
291        use std::sync::Arc;
292        use std::thread;
293
294        let registry = Arc::new(SkillRegistry::new());
295        let mut handles = vec![];
296
297        // Spawn multiple threads that register skills
298        for i in 0..10 {
299            let registry = Arc::clone(&registry);
300            handles.push(thread::spawn(move || {
301                let skill = create_test_skill(&format!("skill-{}", i), &format!("Skill {}", i));
302                registry.register(skill);
303            }));
304        }
305
306        for handle in handles {
307            handle.join().unwrap();
308        }
309
310        assert_eq!(registry.len(), 10);
311    }
312
313    /// Integration test: Discovery -> Registry -> XML workflow
314    #[test]
315    fn test_discovery_to_xml_workflow() {
316        use crate::skills::SkillDiscovery;
317        use std::fs;
318        use tempfile::TempDir;
319
320        // Create test skills directory
321        let temp_dir = TempDir::new().unwrap();
322        let skill_dir = temp_dir.path().join("my-skill");
323        fs::create_dir_all(&skill_dir).unwrap();
324        fs::write(
325            skill_dir.join("SKILL.md"),
326            r#"---
327name: my-skill
328description: A test skill for integration testing.
329---
330
331# My Skill
332
333Instructions for the LLM.
334"#,
335        )
336        .unwrap();
337
338        // Discover skills
339        let mut discovery = SkillDiscovery::empty();
340        discovery.add_path(temp_dir.path().to_path_buf());
341        let discovered = discovery.discover_valid();
342        assert_eq!(discovered.len(), 1);
343
344        // Register in registry
345        let registry = SkillRegistry::new();
346        for skill in discovered {
347            registry.register(skill);
348        }
349
350        // Generate XML for system prompt
351        let xml = registry.to_prompt_xml();
352
353        // Verify XML structure
354        assert!(xml.contains("<available_skills>"));
355        assert!(xml.contains("</available_skills>"));
356        assert!(xml.contains("<name>my-skill</name>"));
357        assert!(xml.contains("<description>A test skill for integration testing.</description>"));
358        assert!(xml.contains("SKILL.md</location>"));
359    }
360}