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 _cancel_token: tokio_util::sync::CancellationToken,
295 ) -> Result<mpsc::Receiver<StreamEvent>> {
296 anyhow::bail!("streaming not used in SkillTool tests")
297 }
298 }
299
300 #[test]
301 fn test_skill_permission_policy() {
302 let skill = Skill {
303 name: "test-skill".to_string(),
304 description: "Test".to_string(),
305 allowed_tools: Some("read(*), grep(*)".to_string()),
306 disable_model_invocation: false,
307 kind: SkillKind::Instruction,
308 content: String::new(),
309 tags: Vec::new(),
310 version: None,
311 };
312
313 let policy = SkillTool::create_skill_permission_policy(&skill);
314
315 assert_eq!(
317 policy.check("read", &serde_json::json!({})),
318 PermissionDecision::Allow
319 );
320 assert_eq!(
321 policy.check("grep", &serde_json::json!({})),
322 PermissionDecision::Allow
323 );
324
325 assert_eq!(
327 policy.check("write", &serde_json::json!({})),
328 PermissionDecision::Deny
329 );
330 }
331
332 #[test]
333 fn test_skill_args_accepts_documented_shape() {
334 let args =
335 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
336 assert_eq!(args.skill_name, "code-review");
337 assert_eq!(args.prompt, None);
338 }
339
340 #[test]
341 fn test_skill_args_accepts_common_aliases_and_wrappers() {
342 let camel =
343 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
344 assert_eq!(camel.skill_name, "code-review");
345
346 let name = SkillArgs::from_tool_args(&serde_json::json!({
347 "name": "code-review",
348 "query": "review this patch"
349 }))
350 .unwrap();
351 assert_eq!(name.skill_name, "code-review");
352 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
353
354 let nested = SkillArgs::from_tool_args(&serde_json::json!({
355 "input": {
356 "skill_name": "code-review",
357 "prompt": "review this patch"
358 }
359 }))
360 .unwrap();
361 assert_eq!(nested.skill_name, "code-review");
362 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
363
364 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
365 assert_eq!(direct.skill_name, "code-review");
366 }
367
368 #[test]
369 fn test_skill_args_missing_skill_name_errors() {
370 let err =
371 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
372 assert!(err.to_string().contains("missing field 'skill_name'"));
373 }
374
375 #[test]
376 fn test_skill_tool_schema_enforces_canonical_shape() {
377 let registry = Arc::new(SkillRegistry::new());
378 let llm = Arc::new(MockLlmClient::new(vec![]));
379 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
380 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
381
382 let params = tool.parameters();
383 assert_eq!(params["type"], "object");
384 assert_eq!(params["additionalProperties"], serde_json::json!(false));
385 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
386
387 let examples = params["examples"].as_array().unwrap();
388 assert_eq!(examples[0]["skill_name"], "code-review");
389 assert!(examples[0].get("name").is_none());
390 assert!(examples[0].get("skillName").is_none());
391 }
392
393 #[tokio::test]
394 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
395 use crate::prompts::PlanningMode;
396
397 let registry = Arc::new(SkillRegistry::new());
398 registry.register_unchecked(Arc::new(Skill {
399 name: "test-skill".to_string(),
400 description: "Run a focused skill".to_string(),
401 allowed_tools: None,
402 disable_model_invocation: false,
403 kind: SkillKind::Instruction,
404 content: "Reply with the skill result.".to_string(),
405 tags: vec!["focus".to_string()],
406 version: None,
407 }));
408
409 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
410 "skill completed",
411 )]));
412 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
413 let mut config = AgentConfig::default();
415 config.planning_mode = PlanningMode::Disabled;
416 let tool = SkillTool::new(registry, llm, executor, config);
417
418 let result = tool
419 .execute(
420 &serde_json::json!({
421 "skill_name": "test-skill",
422 "prompt": "run the skill"
423 }),
424 &ToolContext::new(PathBuf::from("/tmp")),
425 )
426 .await
427 .unwrap();
428
429 assert!(result.success);
430 assert_eq!(result.content, "skill completed");
431 let metadata = result.metadata.unwrap();
432 assert_eq!(metadata["skill_name"], "test-skill");
433 assert_eq!(metadata["tool_calls"], 0);
434 }
435
436 #[tokio::test]
437 async fn test_skill_tool_execute_errors_for_unknown_skill() {
438 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
439 "unused",
440 )]));
441 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
442 let tool = SkillTool::new(
443 Arc::new(SkillRegistry::new()),
444 llm,
445 executor,
446 AgentConfig::default(),
447 );
448
449 let err = tool
450 .execute(
451 &serde_json::json!({"skill_name": "missing-skill"}),
452 &ToolContext::new(PathBuf::from("/tmp")),
453 )
454 .await
455 .unwrap_err();
456
457 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
458 }
459}