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 }]
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 #[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 #[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 #[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 #[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}