1use 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 pub skill_name: String,
20}
21
22#[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 #[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 #[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 #[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 #[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}