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