Skip to main content

deepstrike_core/context/
skill_catalog.rs

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