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            output_schema: None,
47            server_id: None,
48        }]
49    }
50
51    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
52        if call.tool_id != "load_skill" {
53            return Ok(None);
54        }
55        let params: LoadSkillParams = deserialize_params(&call.params)?;
56        let skill_name: String = params.skill_name.chars().take(128).collect();
57        let body = {
58            let guard = self.registry.read();
59            guard.body(&skill_name).map(str::to_owned)
60        };
61
62        let summary = match body {
63            Ok(b) => truncate_tool_output(&b),
64            Err(_) => format!("skill not found: {skill_name}"),
65        };
66
67        Ok(Some(ToolOutput {
68            tool_name: zeph_common::ToolName::new("load_skill"),
69            summary,
70            blocks_executed: 1,
71            filter_stats: None,
72            diff: None,
73            streamed: false,
74            terminal_id: None,
75            locations: None,
76            raw_response: None,
77            claim_source: None,
78        }))
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use std::path::Path;
85
86    use super::*;
87
88    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
89        let skill_dir = dir.join(name);
90        std::fs::create_dir_all(&skill_dir).unwrap();
91        std::fs::write(
92            skill_dir.join("SKILL.md"),
93            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
94        )
95        .unwrap();
96        SkillRegistry::load(&[dir.to_path_buf()])
97    }
98
99    #[tokio::test]
100    async fn load_existing_skill_returns_body() {
101        let dir = tempfile::tempdir().unwrap();
102        let registry =
103            make_registry_with_skill(dir.path(), "git-commit", "## Instructions\nDo git stuff");
104        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
105        let call = ToolCall {
106            tool_id: zeph_common::ToolName::new("load_skill"),
107            params: serde_json::json!({"skill_name": "git-commit"})
108                .as_object()
109                .unwrap()
110                .clone(),
111            caller_id: None,
112            context: None,
113
114            tool_call_id: String::new(),
115            skill_name: None,
116        };
117        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
118        assert!(result.summary.contains("## Instructions"));
119        assert!(result.summary.contains("Do git stuff"));
120    }
121
122    #[tokio::test]
123    async fn load_nonexistent_skill_returns_error_message() {
124        let dir = tempfile::tempdir().unwrap();
125        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
126        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
127        let call = ToolCall {
128            tool_id: zeph_common::ToolName::new("load_skill"),
129            params: serde_json::json!({"skill_name": "nonexistent"})
130                .as_object()
131                .unwrap()
132                .clone(),
133            caller_id: None,
134            context: None,
135
136            tool_call_id: String::new(),
137            skill_name: None,
138        };
139        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
140        assert!(result.summary.contains("skill not found"));
141        assert!(result.summary.contains("nonexistent"));
142    }
143
144    #[test]
145    fn tool_definitions_returns_load_skill() {
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 defs = executor.tool_definitions();
150        assert_eq!(defs.len(), 1);
151        assert_eq!(defs[0].id.as_ref(), "load_skill");
152    }
153
154    #[tokio::test]
155    async fn execute_returns_none_for_wrong_tool_id() {
156        let dir = tempfile::tempdir().unwrap();
157        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
158        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
159        let call = ToolCall {
160            tool_id: zeph_common::ToolName::new("bash"),
161            params: serde_json::Map::new(),
162            caller_id: None,
163            context: None,
164
165            tool_call_id: String::new(),
166            skill_name: None,
167        };
168        let result = executor.execute_tool_call(&call).await.unwrap();
169        assert!(result.is_none());
170    }
171
172    #[tokio::test]
173    async fn long_skill_body_is_truncated() {
174        use zeph_tools::executor::MAX_TOOL_OUTPUT_CHARS;
175        let dir = tempfile::tempdir().unwrap();
176        let long_body = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
177        let registry = make_registry_with_skill(dir.path(), "big-skill", &long_body);
178        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
179        let call = ToolCall {
180            tool_id: zeph_common::ToolName::new("load_skill"),
181            params: serde_json::json!({"skill_name": "big-skill"})
182                .as_object()
183                .unwrap()
184                .clone(),
185            caller_id: None,
186            context: None,
187
188            tool_call_id: String::new(),
189            skill_name: None,
190        };
191        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
192        assert!(result.summary.contains("truncated"));
193        assert!(result.summary.len() < long_body.len() + 200);
194    }
195
196    #[tokio::test]
197    async fn empty_registry_returns_error_message() {
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 call = ToolCall {
202            tool_id: zeph_common::ToolName::new("load_skill"),
203            params: serde_json::json!({"skill_name": "any"})
204                .as_object()
205                .unwrap()
206                .clone(),
207            caller_id: None,
208            context: None,
209
210            tool_call_id: String::new(),
211            skill_name: None,
212        };
213        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
214        assert!(result.summary.contains("skill not found"));
215    }
216
217    // GAP-1: direct execute() always returns None
218    #[tokio::test]
219    async fn execute_always_returns_none() {
220        let dir = tempfile::tempdir().unwrap();
221        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
222        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
223        let result = executor.execute("any response text").await.unwrap();
224        assert!(result.is_none());
225    }
226
227    // GAP-2: concurrent reads all succeed
228    #[tokio::test]
229    async fn concurrent_execute_tool_call_succeeds() {
230        let dir = tempfile::tempdir().unwrap();
231        let registry =
232            make_registry_with_skill(dir.path(), "shared-skill", "## Concurrent test body");
233        let executor = Arc::new(SkillLoaderExecutor::new(Arc::new(RwLock::new(registry))));
234
235        let handles: Vec<_> = (0..8)
236            .map(|_| {
237                let ex = Arc::clone(&executor);
238                tokio::spawn(async move {
239                    let call = ToolCall {
240                        tool_id: zeph_common::ToolName::new("load_skill"),
241                        params: serde_json::json!({"skill_name": "shared-skill"})
242                            .as_object()
243                            .unwrap()
244                            .clone(),
245                        caller_id: None,
246                        context: None,
247
248                        tool_call_id: String::new(),
249                        skill_name: None,
250                    };
251                    ex.execute_tool_call(&call).await
252                })
253            })
254            .collect();
255
256        for h in handles {
257            let result = h.await.unwrap().unwrap().unwrap();
258            assert!(result.summary.contains("## Concurrent test body"));
259        }
260    }
261
262    // GAP-3: empty skill_name returns "not found"
263    #[tokio::test]
264    async fn empty_skill_name_returns_not_found() {
265        let dir = tempfile::tempdir().unwrap();
266        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
267        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
268        let call = ToolCall {
269            tool_id: zeph_common::ToolName::new("load_skill"),
270            params: serde_json::json!({"skill_name": ""})
271                .as_object()
272                .unwrap()
273                .clone(),
274            caller_id: None,
275            context: None,
276
277            tool_call_id: String::new(),
278            skill_name: None,
279        };
280        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
281        assert!(result.summary.contains("skill not found"));
282    }
283
284    // GAP-4: missing skill_name field returns ToolError from deserialize_params
285    #[tokio::test]
286    async fn missing_skill_name_field_returns_error() {
287        let dir = tempfile::tempdir().unwrap();
288        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
289        let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
290        let call = ToolCall {
291            tool_id: zeph_common::ToolName::new("load_skill"),
292            params: serde_json::Map::new(),
293            caller_id: None,
294            context: None,
295
296            tool_call_id: String::new(),
297            skill_name: None,
298        };
299        let result = executor.execute_tool_call(&call).await;
300        assert!(result.is_err());
301    }
302}