Skip to main content

zeph_core/
skill_loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::sync::Arc;
5
6use parking_lot::RwLock;
7
8use schemars::JsonSchema;
9use serde::Deserialize;
10use zeph_skills::registry::SkillRegistry;
11use zeph_tools::executor::{
12    ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
13};
14use zeph_tools::registry::{InvocationHint, ToolDef};
15
16#[derive(Debug, Deserialize, JsonSchema)]
17pub struct LoadSkillParams {
18    /// Name of the skill to load (from `<other_skills>` catalog).
19    pub skill_name: String,
20}
21
22/// Tool executor that loads a full skill body by name from the shared registry.
23#[derive(Clone, Debug)]
24pub struct SkillLoaderExecutor {
25    registry: Arc<RwLock<SkillRegistry>>,
26}
27
28impl SkillLoaderExecutor {
29    #[must_use]
30    pub fn new(registry: Arc<RwLock<SkillRegistry>>) -> Self {
31        Self { registry }
32    }
33}
34
35impl ToolExecutor for SkillLoaderExecutor {
36    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
37        Ok(None)
38    }
39
40    fn tool_definitions(&self) -> Vec<ToolDef> {
41        vec![ToolDef {
42            id: "load_skill".into(),
43            description: "Load the full body of a skill by name when you see a relevant entry in the <other_skills> catalog.\n\nParameters: name (string, required) - exact skill name from the <other_skills> catalog\nReturns: complete skill instructions (SKILL.md body), or error if skill not found\nErrors: InvalidParams if name is empty; Execution if skill not found in registry\nExample: {\"name\": \"code-review\"}".into(),
44            schema: schemars::schema_for!(LoadSkillParams),
45            invocation: InvocationHint::ToolCall,
46        }]
47    }
48
49    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
50        if call.tool_id != "load_skill" {
51            return Ok(None);
52        }
53        let params: LoadSkillParams = deserialize_params(&call.params)?;
54        let skill_name: String = params.skill_name.chars().take(128).collect();
55        let body = {
56            let guard = self.registry.read();
57            guard.get_body(&skill_name).map(str::to_owned)
58        };
59
60        let summary = match body {
61            Ok(b) => truncate_tool_output(&b),
62            Err(_) => format!("skill not found: {skill_name}"),
63        };
64
65        Ok(Some(ToolOutput {
66            tool_name: zeph_common::ToolName::new("load_skill"),
67            summary,
68            blocks_executed: 1,
69            filter_stats: None,
70            diff: None,
71            streamed: false,
72            terminal_id: None,
73            locations: None,
74            raw_response: None,
75            claim_source: None,
76        }))
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::path::Path;
83
84    use super::*;
85
86    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
87        let skill_dir = dir.join(name);
88        std::fs::create_dir_all(&skill_dir).unwrap();
89        std::fs::write(
90            skill_dir.join("SKILL.md"),
91            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
92        )
93        .unwrap();
94        SkillRegistry::load(&[dir.to_path_buf()])
95    }
96
97    #[tokio::test]
98    async fn load_existing_skill_returns_body() {
99        let dir = tempfile::tempdir().unwrap();
100        let registry =
101            make_registry_with_skill(dir.path(), "git-commit", "## Instructions\nDo git stuff");
102        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
103        let call = ToolCall {
104            tool_id: zeph_common::ToolName::new("load_skill"),
105            params: serde_json::json!({"skill_name": "git-commit"})
106                .as_object()
107                .unwrap()
108                .clone(),
109            caller_id: None,
110        };
111        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
112        assert!(result.summary.contains("## Instructions"));
113        assert!(result.summary.contains("Do git stuff"));
114    }
115
116    #[tokio::test]
117    async fn load_nonexistent_skill_returns_error_message() {
118        let dir = tempfile::tempdir().unwrap();
119        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
120        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
121        let call = ToolCall {
122            tool_id: zeph_common::ToolName::new("load_skill"),
123            params: serde_json::json!({"skill_name": "nonexistent"})
124                .as_object()
125                .unwrap()
126                .clone(),
127            caller_id: None,
128        };
129        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
130        assert!(result.summary.contains("skill not found"));
131        assert!(result.summary.contains("nonexistent"));
132    }
133
134    #[test]
135    fn tool_definitions_returns_load_skill() {
136        let dir = tempfile::tempdir().unwrap();
137        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
138        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
139        let defs = executor.tool_definitions();
140        assert_eq!(defs.len(), 1);
141        assert_eq!(defs[0].id.as_ref(), "load_skill");
142    }
143
144    #[tokio::test]
145    async fn execute_returns_none_for_wrong_tool_id() {
146        let dir = tempfile::tempdir().unwrap();
147        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
148        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
149        let call = ToolCall {
150            tool_id: zeph_common::ToolName::new("bash"),
151            params: serde_json::Map::new(),
152            caller_id: None,
153        };
154        let result = executor.execute_tool_call(&call).await.unwrap();
155        assert!(result.is_none());
156    }
157
158    #[tokio::test]
159    async fn long_skill_body_is_truncated() {
160        use zeph_tools::executor::MAX_TOOL_OUTPUT_CHARS;
161        let dir = tempfile::tempdir().unwrap();
162        let long_body = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
163        let registry = make_registry_with_skill(dir.path(), "big-skill", &long_body);
164        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
165        let call = ToolCall {
166            tool_id: zeph_common::ToolName::new("load_skill"),
167            params: serde_json::json!({"skill_name": "big-skill"})
168                .as_object()
169                .unwrap()
170                .clone(),
171            caller_id: None,
172        };
173        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
174        assert!(result.summary.contains("truncated"));
175        assert!(result.summary.len() < long_body.len() + 200);
176    }
177
178    #[tokio::test]
179    async fn empty_registry_returns_error_message() {
180        let dir = tempfile::tempdir().unwrap();
181        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
182        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
183        let call = ToolCall {
184            tool_id: zeph_common::ToolName::new("load_skill"),
185            params: serde_json::json!({"skill_name": "any"})
186                .as_object()
187                .unwrap()
188                .clone(),
189            caller_id: None,
190        };
191        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
192        assert!(result.summary.contains("skill not found"));
193    }
194
195    // GAP-1: direct execute() always returns None
196    #[tokio::test]
197    async fn execute_always_returns_none() {
198        let dir = tempfile::tempdir().unwrap();
199        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
200        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
201        let result = executor.execute("any response text").await.unwrap();
202        assert!(result.is_none());
203    }
204
205    // GAP-2: concurrent reads all succeed
206    #[tokio::test]
207    async fn concurrent_execute_tool_call_succeeds() {
208        let dir = tempfile::tempdir().unwrap();
209        let registry =
210            make_registry_with_skill(dir.path(), "shared-skill", "## Concurrent test body");
211        let executor = Arc::new(SkillLoaderExecutor::new(Arc::new(RwLock::new(registry))));
212
213        let handles: Vec<_> = (0..8)
214            .map(|_| {
215                let ex = Arc::clone(&executor);
216                tokio::spawn(async move {
217                    let call = ToolCall {
218                        tool_id: zeph_common::ToolName::new("load_skill"),
219                        params: serde_json::json!({"skill_name": "shared-skill"})
220                            .as_object()
221                            .unwrap()
222                            .clone(),
223                        caller_id: None,
224                    };
225                    ex.execute_tool_call(&call).await
226                })
227            })
228            .collect();
229
230        for h in handles {
231            let result = h.await.unwrap().unwrap().unwrap();
232            assert!(result.summary.contains("## Concurrent test body"));
233        }
234    }
235
236    // GAP-3: empty skill_name returns "not found"
237    #[tokio::test]
238    async fn empty_skill_name_returns_not_found() {
239        let dir = tempfile::tempdir().unwrap();
240        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
241        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
242        let call = ToolCall {
243            tool_id: zeph_common::ToolName::new("load_skill"),
244            params: serde_json::json!({"skill_name": ""})
245                .as_object()
246                .unwrap()
247                .clone(),
248            caller_id: None,
249        };
250        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
251        assert!(result.summary.contains("skill not found"));
252    }
253
254    // GAP-4: missing skill_name field returns ToolError from deserialize_params
255    #[tokio::test]
256    async fn missing_skill_name_field_returns_error() {
257        let dir = tempfile::tempdir().unwrap();
258        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
259        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
260        let call = ToolCall {
261            tool_id: zeph_common::ToolName::new("load_skill"),
262            params: serde_json::Map::new(),
263            caller_id: None,
264        };
265        let result = executor.execute_tool_call(&call).await;
266        assert!(result.is_err());
267    }
268}