zeph_core/
skill_loader.rs1use 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 }]
47 }
48
49 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
50 if call.tool_id != "load_skill" {
51 return Ok(None);
52 }
53 let params: LoadSkillParams = deserialize_params(&call.params)?;
54 let skill_name: String = params.skill_name.chars().take(128).collect();
55 let body = {
56 let guard = self.registry.read();
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: zeph_common::ToolName::new("load_skill"),
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 claim_source: None,
76 }))
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use std::path::Path;
83
84 use super::*;
85
86 fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
87 let skill_dir = dir.join(name);
88 std::fs::create_dir_all(&skill_dir).unwrap();
89 std::fs::write(
90 skill_dir.join("SKILL.md"),
91 format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
92 )
93 .unwrap();
94 SkillRegistry::load(&[dir.to_path_buf()])
95 }
96
97 #[tokio::test]
98 async fn load_existing_skill_returns_body() {
99 let dir = tempfile::tempdir().unwrap();
100 let registry =
101 make_registry_with_skill(dir.path(), "git-commit", "## Instructions\nDo git stuff");
102 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
103 let call = ToolCall {
104 tool_id: zeph_common::ToolName::new("load_skill"),
105 params: serde_json::json!({"skill_name": "git-commit"})
106 .as_object()
107 .unwrap()
108 .clone(),
109 caller_id: None,
110 };
111 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
112 assert!(result.summary.contains("## Instructions"));
113 assert!(result.summary.contains("Do git stuff"));
114 }
115
116 #[tokio::test]
117 async fn load_nonexistent_skill_returns_error_message() {
118 let dir = tempfile::tempdir().unwrap();
119 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
120 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
121 let call = ToolCall {
122 tool_id: zeph_common::ToolName::new("load_skill"),
123 params: serde_json::json!({"skill_name": "nonexistent"})
124 .as_object()
125 .unwrap()
126 .clone(),
127 caller_id: None,
128 };
129 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
130 assert!(result.summary.contains("skill not found"));
131 assert!(result.summary.contains("nonexistent"));
132 }
133
134 #[test]
135 fn tool_definitions_returns_load_skill() {
136 let dir = tempfile::tempdir().unwrap();
137 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
138 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
139 let defs = executor.tool_definitions();
140 assert_eq!(defs.len(), 1);
141 assert_eq!(defs[0].id.as_ref(), "load_skill");
142 }
143
144 #[tokio::test]
145 async fn execute_returns_none_for_wrong_tool_id() {
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 call = ToolCall {
150 tool_id: zeph_common::ToolName::new("bash"),
151 params: serde_json::Map::new(),
152 caller_id: None,
153 };
154 let result = executor.execute_tool_call(&call).await.unwrap();
155 assert!(result.is_none());
156 }
157
158 #[tokio::test]
159 async fn long_skill_body_is_truncated() {
160 use zeph_tools::executor::MAX_TOOL_OUTPUT_CHARS;
161 let dir = tempfile::tempdir().unwrap();
162 let long_body = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
163 let registry = make_registry_with_skill(dir.path(), "big-skill", &long_body);
164 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
165 let call = ToolCall {
166 tool_id: zeph_common::ToolName::new("load_skill"),
167 params: serde_json::json!({"skill_name": "big-skill"})
168 .as_object()
169 .unwrap()
170 .clone(),
171 caller_id: None,
172 };
173 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
174 assert!(result.summary.contains("truncated"));
175 assert!(result.summary.len() < long_body.len() + 200);
176 }
177
178 #[tokio::test]
179 async fn empty_registry_returns_error_message() {
180 let dir = tempfile::tempdir().unwrap();
181 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
182 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
183 let call = ToolCall {
184 tool_id: zeph_common::ToolName::new("load_skill"),
185 params: serde_json::json!({"skill_name": "any"})
186 .as_object()
187 .unwrap()
188 .clone(),
189 caller_id: None,
190 };
191 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
192 assert!(result.summary.contains("skill not found"));
193 }
194
195 #[tokio::test]
197 async fn execute_always_returns_none() {
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 result = executor.execute("any response text").await.unwrap();
202 assert!(result.is_none());
203 }
204
205 #[tokio::test]
207 async fn concurrent_execute_tool_call_succeeds() {
208 let dir = tempfile::tempdir().unwrap();
209 let registry =
210 make_registry_with_skill(dir.path(), "shared-skill", "## Concurrent test body");
211 let executor = Arc::new(SkillLoaderExecutor::new(Arc::new(RwLock::new(registry))));
212
213 let handles: Vec<_> = (0..8)
214 .map(|_| {
215 let ex = Arc::clone(&executor);
216 tokio::spawn(async move {
217 let call = ToolCall {
218 tool_id: zeph_common::ToolName::new("load_skill"),
219 params: serde_json::json!({"skill_name": "shared-skill"})
220 .as_object()
221 .unwrap()
222 .clone(),
223 caller_id: None,
224 };
225 ex.execute_tool_call(&call).await
226 })
227 })
228 .collect();
229
230 for h in handles {
231 let result = h.await.unwrap().unwrap().unwrap();
232 assert!(result.summary.contains("## Concurrent test body"));
233 }
234 }
235
236 #[tokio::test]
238 async fn empty_skill_name_returns_not_found() {
239 let dir = tempfile::tempdir().unwrap();
240 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
241 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
242 let call = ToolCall {
243 tool_id: zeph_common::ToolName::new("load_skill"),
244 params: serde_json::json!({"skill_name": ""})
245 .as_object()
246 .unwrap()
247 .clone(),
248 caller_id: None,
249 };
250 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
251 assert!(result.summary.contains("skill not found"));
252 }
253
254 #[tokio::test]
256 async fn missing_skill_name_field_returns_error() {
257 let dir = tempfile::tempdir().unwrap();
258 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
259 let executor = SkillLoaderExecutor::new(Arc::new(RwLock::new(registry)));
260 let call = ToolCall {
261 tool_id: zeph_common::ToolName::new("load_skill"),
262 params: serde_json::Map::new(),
263 caller_id: None,
264 };
265 let result = executor.execute_tool_call(&call).await;
266 assert!(result.is_err());
267 }
268}