1use crate::agent::{AgentConfig, AgentLoop};
17use crate::llm::LlmClient;
18use crate::permissions::{PermissionDecision, PermissionPolicy, PermissionRule};
19use crate::skills::{Skill, SkillRegistry};
20use crate::tools::{Tool, ToolContext, ToolExecutor, ToolOutput};
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::sync::Arc;
26
27#[derive(Debug, Serialize, Deserialize)]
29pub struct SkillArgs {
30 pub skill_name: String,
32 #[serde(default)]
34 pub prompt: Option<String>,
35}
36
37impl SkillArgs {
38 fn from_tool_args(args: &Value) -> Result<Self> {
39 fn parse_from_value(value: &Value) -> Option<SkillArgs> {
40 match value {
41 Value::String(skill_name) => Some(SkillArgs {
42 skill_name: skill_name.clone(),
43 prompt: None,
44 }),
45 Value::Object(map) => {
46 if let Some(skill_name) = map
47 .get("skill_name")
48 .or_else(|| map.get("skillName"))
49 .or_else(|| map.get("name"))
50 .and_then(|v| v.as_str())
51 {
52 let prompt = map
53 .get("prompt")
54 .or_else(|| map.get("query"))
55 .and_then(|v| v.as_str())
56 .map(ToOwned::to_owned);
57 return Some(SkillArgs {
58 skill_name: skill_name.to_string(),
59 prompt,
60 });
61 }
62
63 if let Some(nested) = map.get("input").or_else(|| map.get("arguments")) {
64 if let Some(parsed) = parse_from_value(nested) {
65 return Some(parsed);
66 }
67 }
68
69 None
70 }
71 _ => None,
72 }
73 }
74
75 parse_from_value(args).ok_or_else(|| anyhow!("missing field 'skill_name'"))
76 }
77}
78
79pub struct SkillTool {
81 skill_registry: Arc<SkillRegistry>,
82 llm_client: Arc<dyn LlmClient>,
83 tool_executor: Arc<ToolExecutor>,
84 base_config: AgentConfig,
85}
86
87impl SkillTool {
88 pub fn new(
89 skill_registry: Arc<SkillRegistry>,
90 llm_client: Arc<dyn LlmClient>,
91 tool_executor: Arc<ToolExecutor>,
92 base_config: AgentConfig,
93 ) -> Self {
94 Self {
95 skill_registry,
96 llm_client,
97 tool_executor,
98 base_config,
99 }
100 }
101
102 fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
104 let permissions = skill.parse_allowed_tools();
105
106 let mut allow_rules = Vec::new();
108 for perm in permissions {
109 let rule_str = if perm.pattern == "*" {
111 perm.tool.clone()
112 } else {
113 format!("{}({})", perm.tool, perm.pattern)
114 };
115 allow_rules.push(PermissionRule::new(&rule_str));
116 }
117
118 PermissionPolicy {
119 deny: Vec::new(),
120 allow: allow_rules,
121 ask: Vec::new(),
122 default_decision: PermissionDecision::Deny, enabled: true,
124 }
125 }
126}
127
128#[async_trait]
129impl Tool for SkillTool {
130 fn name(&self) -> &str {
131 "Skill"
132 }
133
134 fn description(&self) -> &str {
135 "Invoke a skill with temporary permission grants. \
136Use a JSON object with the canonical shape {\"skill_name\":\"<skill-name>\",\"prompt\":\"<optional prompt>\"}. \
137Always send the skill name in the 'skill_name' field. Do not use aliases such as 'name' or 'skillName', and do not wrap the payload in 'input' or 'arguments'. \
138The skill's allowed-tools are granted during execution and revoked after completion."
139 }
140
141 fn parameters(&self) -> Value {
142 serde_json::json!({
143 "type": "object",
144 "additionalProperties": false,
145 "properties": {
146 "skill_name": {
147 "type": "string",
148 "description": "Required. Canonical skill identifier to invoke. Always provide this exact field name: 'skill_name'."
149 },
150 "prompt": {
151 "type": "string",
152 "description": "Optional prompt or query to pass to the skill after it is loaded."
153 }
154 },
155 "required": ["skill_name"],
156 "examples": [
157 {
158 "skill_name": "code-review"
159 },
160 {
161 "skill_name": "code-review",
162 "prompt": "Review this patch for correctness and regressions."
163 }
164 ]
165 })
166 }
167
168 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
169 let args = SkillArgs::from_tool_args(args)?;
170
171 let skill = self
173 .skill_registry
174 .get(&args.skill_name)
175 .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
176
177 let skill_permission_policy = Self::create_skill_permission_policy(&skill);
179
180 let mut skill_config = self.base_config.clone();
182
183 skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
185
186 let temp_registry = Arc::new(SkillRegistry::new());
188 temp_registry.register(skill.clone())?;
189 skill_config.skill_registry = Some(temp_registry);
190
191 skill_config.prompt_slots.role = Some(format!(
193 "You are executing the '{}' skill.\n\n{}\n\n{}",
194 skill.name, skill.description, skill.content
195 ));
196
197 let agent_loop = AgentLoop::new(
199 self.llm_client.clone(),
200 self.tool_executor.clone(),
201 ctx.clone(),
202 skill_config,
203 );
204
205 let prompt = args
207 .prompt
208 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
209
210 let result = agent_loop.execute(&[], &prompt, None).await?;
212
213 Ok(ToolOutput {
215 content: result.text,
216 success: true,
217 metadata: Some(serde_json::json!({
218 "skill_name": skill.name,
219 "tool_calls": result.tool_calls_count,
220 "usage": result.usage,
221 })),
222 images: Vec::new(),
223 })
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::llm::{
231 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
232 };
233 use crate::skills::SkillKind;
234 use crate::tools::ToolContext;
235 use anyhow::Result;
236 use async_trait::async_trait;
237 use std::path::PathBuf;
238 use std::sync::Mutex;
239 use tokio::sync::mpsc;
240
241 struct MockLlmClient {
242 responses: Mutex<Vec<LlmResponse>>,
243 }
244
245 impl MockLlmClient {
246 fn new(responses: Vec<LlmResponse>) -> Self {
247 Self {
248 responses: Mutex::new(responses),
249 }
250 }
251
252 fn text_response(text: &str) -> LlmResponse {
253 LlmResponse {
254 message: Message {
255 role: "assistant".to_string(),
256 content: vec![ContentBlock::Text {
257 text: text.to_string(),
258 }],
259 reasoning_content: None,
260 },
261 usage: TokenUsage {
262 prompt_tokens: 10,
263 completion_tokens: 5,
264 total_tokens: 15,
265 cache_read_tokens: None,
266 cache_write_tokens: None,
267 },
268 stop_reason: Some("end_turn".to_string()),
269 meta: None,
270 }
271 }
272 }
273
274 #[async_trait]
275 impl LlmClient for MockLlmClient {
276 async fn complete(
277 &self,
278 _messages: &[Message],
279 _system: Option<&str>,
280 _tools: &[ToolDefinition],
281 ) -> Result<LlmResponse> {
282 let mut responses = self.responses.lock().unwrap();
283 if responses.is_empty() {
284 anyhow::bail!("No more mock responses available");
285 }
286 Ok(responses.remove(0))
287 }
288
289 async fn complete_streaming(
290 &self,
291 _messages: &[Message],
292 _system: Option<&str>,
293 _tools: &[ToolDefinition],
294 ) -> Result<mpsc::Receiver<StreamEvent>> {
295 anyhow::bail!("streaming not used in SkillTool tests")
296 }
297 }
298
299 #[test]
300 fn test_skill_permission_policy() {
301 let skill = Skill {
302 name: "test-skill".to_string(),
303 description: "Test".to_string(),
304 allowed_tools: Some("read(*), grep(*)".to_string()),
305 disable_model_invocation: false,
306 kind: SkillKind::Instruction,
307 content: String::new(),
308 tags: Vec::new(),
309 version: None,
310 };
311
312 let policy = SkillTool::create_skill_permission_policy(&skill);
313
314 assert_eq!(
316 policy.check("read", &serde_json::json!({})),
317 PermissionDecision::Allow
318 );
319 assert_eq!(
320 policy.check("grep", &serde_json::json!({})),
321 PermissionDecision::Allow
322 );
323
324 assert_eq!(
326 policy.check("write", &serde_json::json!({})),
327 PermissionDecision::Deny
328 );
329 }
330
331 #[test]
332 fn test_skill_args_accepts_documented_shape() {
333 let args =
334 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
335 assert_eq!(args.skill_name, "code-review");
336 assert_eq!(args.prompt, None);
337 }
338
339 #[test]
340 fn test_skill_args_accepts_common_aliases_and_wrappers() {
341 let camel =
342 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
343 assert_eq!(camel.skill_name, "code-review");
344
345 let name = SkillArgs::from_tool_args(&serde_json::json!({
346 "name": "code-review",
347 "query": "review this patch"
348 }))
349 .unwrap();
350 assert_eq!(name.skill_name, "code-review");
351 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
352
353 let nested = SkillArgs::from_tool_args(&serde_json::json!({
354 "input": {
355 "skill_name": "code-review",
356 "prompt": "review this patch"
357 }
358 }))
359 .unwrap();
360 assert_eq!(nested.skill_name, "code-review");
361 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
362
363 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
364 assert_eq!(direct.skill_name, "code-review");
365 }
366
367 #[test]
368 fn test_skill_args_missing_skill_name_errors() {
369 let err =
370 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
371 assert!(err.to_string().contains("missing field 'skill_name'"));
372 }
373
374 #[test]
375 fn test_skill_tool_schema_enforces_canonical_shape() {
376 let registry = Arc::new(SkillRegistry::new());
377 let llm = Arc::new(MockLlmClient::new(vec![]));
378 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
379 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
380
381 let params = tool.parameters();
382 assert_eq!(params["type"], "object");
383 assert_eq!(params["additionalProperties"], serde_json::json!(false));
384 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
385
386 let examples = params["examples"].as_array().unwrap();
387 assert_eq!(examples[0]["skill_name"], "code-review");
388 assert!(examples[0].get("name").is_none());
389 assert!(examples[0].get("skillName").is_none());
390 }
391
392 #[tokio::test]
393 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
394 let registry = Arc::new(SkillRegistry::new());
395 registry.register_unchecked(Arc::new(Skill {
396 name: "test-skill".to_string(),
397 description: "Run a focused skill".to_string(),
398 allowed_tools: None,
399 disable_model_invocation: false,
400 kind: SkillKind::Instruction,
401 content: "Reply with the skill result.".to_string(),
402 tags: vec!["focus".to_string()],
403 version: None,
404 }));
405
406 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
407 "skill completed",
408 )]));
409 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
410 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
411
412 let result = tool
413 .execute(
414 &serde_json::json!({
415 "skill_name": "test-skill",
416 "prompt": "run the skill"
417 }),
418 &ToolContext::new(PathBuf::from("/tmp")),
419 )
420 .await
421 .unwrap();
422
423 assert!(result.success);
424 assert_eq!(result.content, "skill completed");
425 let metadata = result.metadata.unwrap();
426 assert_eq!(metadata["skill_name"], "test-skill");
427 assert_eq!(metadata["tool_calls"], 0);
428 }
429
430 #[tokio::test]
431 async fn test_skill_tool_execute_errors_for_unknown_skill() {
432 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
433 "unused",
434 )]));
435 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
436 let tool = SkillTool::new(
437 Arc::new(SkillRegistry::new()),
438 llm,
439 executor,
440 AgentConfig::default(),
441 );
442
443 let err = tool
444 .execute(
445 &serde_json::json!({"skill_name": "missing-skill"}),
446 &ToolContext::new(PathBuf::from("/tmp")),
447 )
448 .await
449 .unwrap_err();
450
451 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
452 }
453}