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