Skip to main content

deepstrike_core/context/
skill_catalog.rs

1use compact_str::CompactString;
2use std::collections::HashMap;
3
4use crate::types::message::ToolSchema;
5use crate::types::skill::SkillMetadata;
6
7/// The built-in meta-tool name the kernel injects when skills are registered.
8pub const SKILL_TOOL_NAME: &str = "skill";
9
10/// Registry of available skills.
11///
12/// In the progressive-disclosure model the catalog has one responsibility:
13/// know *what* skills exist (name + description) and build the dynamic
14/// `skill` meta-tool schema that is included in every `CallLLM` action so
15/// the model can invoke any skill by name.
16///
17/// Skill *content* is never held here — it is returned to the LLM as a
18/// regular tool-call result by the SDK layer (read from disk on demand).
19pub struct SkillCatalog {
20    available: HashMap<CompactString, SkillMetadata>,
21}
22
23impl Default for SkillCatalog {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl SkillCatalog {
30    pub fn new() -> Self {
31        Self {
32            available: HashMap::new(),
33        }
34    }
35
36    /// Replace the full available-skills set in one shot.
37    pub fn set_available(&mut self, skills: Vec<SkillMetadata>) {
38        self.available = skills.into_iter().map(|s| (s.name.clone(), s)).collect();
39    }
40
41    /// Add or replace a single skill entry.
42    pub fn upsert_available(&mut self, skill: SkillMetadata) {
43        self.available.insert(skill.name.clone(), skill);
44    }
45
46    pub fn available_count(&self) -> usize {
47        self.available.len()
48    }
49
50    /// P1-B tool gating: the tool ids the named skill declares it needs. Empty when the skill is
51    /// unknown or declares none (⇒ that skill does not narrow the toolset). The kernel unions these
52    /// across the active-skill set in `emit_call_llm`.
53    pub fn allowed_tools(&self, name: &str) -> &[CompactString] {
54        self.available
55            .get(name)
56            .map(|s| s.allowed_tools.as_slice())
57            .unwrap_or(&[])
58    }
59
60    pub fn is_empty(&self) -> bool {
61        self.available.is_empty()
62    }
63
64    /// Build the dynamic skill meta-tool schema to inject into every LLM call.
65    ///
66    /// Returns `None` when no skills are registered (nothing to inject).
67    /// The `description` field embeds the full `<available_skills>` XML so
68    /// the model learns what is available without a separate system message.
69    pub fn build_tool_schema(&self) -> Option<ToolSchema> {
70        if self.available.is_empty() {
71            return None;
72        }
73
74        let mut skills: Vec<&SkillMetadata> = self.available.values().collect();
75        skills.sort_by_key(|s| s.name.as_str());
76
77        let mut xml = String::from("<available_skills>\n");
78        for meta in &skills {
79            xml.push_str(&format!(
80                "  <skill>\n    <name>{}</name>\n    <description>{}</description>\n",
81                meta.name, meta.description,
82            ));
83            if let Some(ref w) = meta.when_to_use {
84                xml.push_str(&format!("    <when_to_use>{w}</when_to_use>\n"));
85            }
86            if let Some(e) = meta.effort {
87                xml.push_str(&format!("    <effort>{e}</effort>\n"));
88            }
89            xml.push_str("  </skill>\n");
90        }
91        xml.push_str("</available_skills>");
92
93        Some(ToolSchema {
94            name: CompactString::new(SKILL_TOOL_NAME),
95            description: format!(
96                "Load a skill into your context to access specialized instructions for a task.\n\n{xml}"
97            ),
98            parameters: serde_json::json!({
99                "type": "object",
100                "properties": {
101                    "name": {
102                        "type": "string",
103                        "description": "The name of the skill to load."
104                    }
105                },
106                "required": ["name"]
107            }),
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::types::skill::SkillMetadata;
116
117    #[test]
118    fn empty_catalog_returns_no_schema() {
119        let catalog = SkillCatalog::new();
120        assert!(catalog.build_tool_schema().is_none());
121        assert!(catalog.is_empty());
122    }
123
124    #[test]
125    fn single_skill_builds_schema() {
126        let mut catalog = SkillCatalog::new();
127        catalog.set_available(vec![SkillMetadata::new("debug", "Debug helper")]);
128        let schema = catalog.build_tool_schema().unwrap();
129        assert_eq!(schema.name.as_str(), SKILL_TOOL_NAME);
130        assert!(schema.description.contains("debug"));
131        assert!(schema.description.contains("Debug helper"));
132        assert!(schema.description.contains("<available_skills>"));
133    }
134
135    #[test]
136    fn set_available_replaces_previous() {
137        let mut catalog = SkillCatalog::new();
138        catalog.set_available(vec![SkillMetadata::new("old", "Old skill")]);
139        catalog.set_available(vec![SkillMetadata::new("new", "New skill")]);
140        assert_eq!(catalog.available_count(), 1);
141        let schema = catalog.build_tool_schema().unwrap();
142        assert!(schema.description.contains("new"));
143        assert!(!schema.description.contains("old"));
144    }
145
146    #[test]
147    fn multiple_skills_all_appear_in_schema() {
148        let mut catalog = SkillCatalog::new();
149        catalog.set_available(vec![
150            SkillMetadata::new("alpha", "Alpha skill"),
151            SkillMetadata::new("beta", "Beta skill"),
152        ]);
153        let schema = catalog.build_tool_schema().unwrap();
154        assert!(schema.description.contains("alpha"));
155        assert!(schema.description.contains("beta"));
156    }
157
158    #[test]
159    fn upsert_adds_single_skill() {
160        let mut catalog = SkillCatalog::new();
161        catalog.upsert_available(SkillMetadata::new("solo", "Solo skill"));
162        assert_eq!(catalog.available_count(), 1);
163        assert!(!catalog.is_empty());
164    }
165
166    #[test]
167    fn allowed_tools_round_trip_through_catalog() {
168        // P1-B B0: a skill's declared `allowed_tools` survives registration and is looked up by name.
169        let mut skill = SkillMetadata::new("debug", "Debug helper");
170        skill.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
171        let mut catalog = SkillCatalog::new();
172        catalog.set_available(vec![skill]);
173
174        let tools = catalog.allowed_tools("debug");
175        assert_eq!(tools.len(), 2);
176        assert!(tools.iter().any(|t| t == "read"));
177        assert!(tools.iter().any(|t| t == "grep"));
178        // Unknown skill / skill with no declaration ⇒ empty (no narrowing).
179        assert!(catalog.allowed_tools("missing").is_empty());
180    }
181}