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
79#[derive(Debug, Serialize, Deserialize)]
81pub struct SearchSkillsArgs {
82 pub query: String,
84 #[serde(default)]
86 pub limit: Option<usize>,
87}
88
89impl SearchSkillsArgs {
90 fn from_tool_args(args: &Value) -> Result<Self> {
91 match args {
92 Value::String(query) => Ok(Self {
93 query: query.clone(),
94 limit: None,
95 }),
96 Value::Object(map) => {
97 let query = map
98 .get("query")
99 .or_else(|| map.get("q"))
100 .and_then(|v| v.as_str())
101 .ok_or_else(|| anyhow!("missing field 'query'"))?
102 .to_string();
103 let limit = map
104 .get("limit")
105 .and_then(|v| v.as_u64())
106 .map(|v| v as usize);
107 Ok(Self { query, limit })
108 }
109 _ => Err(anyhow!(
110 "search_skills expects an object with a 'query' field"
111 )),
112 }
113 }
114}
115
116pub struct SearchSkillsTool {
118 skill_registry: Arc<SkillRegistry>,
119}
120
121impl SearchSkillsTool {
122 pub fn new(skill_registry: Arc<SkillRegistry>) -> Self {
123 Self { skill_registry }
124 }
125}
126
127#[async_trait]
128impl Tool for SearchSkillsTool {
129 fn name(&self) -> &str {
130 "search_skills"
131 }
132
133 fn description(&self) -> &str {
134 "Search available skills by name, tag, description, or content. \
135Use this before invoking Skill when specialized instructions may help."
136 }
137
138 fn parameters(&self) -> Value {
139 serde_json::json!({
140 "type": "object",
141 "additionalProperties": false,
142 "properties": {
143 "query": {
144 "type": "string",
145 "description": "Short search query for the skill you need."
146 },
147 "limit": {
148 "type": "integer",
149 "minimum": 1,
150 "maximum": 20,
151 "description": "Maximum number of skills to return. Defaults to 5."
152 }
153 },
154 "required": ["query"]
155 })
156 }
157
158 async fn execute(&self, args: &Value, _ctx: &ToolContext) -> Result<ToolOutput> {
159 let args = SearchSkillsArgs::from_tool_args(args)?;
160 let limit = args.limit.unwrap_or(5).clamp(1, 20);
161 let matches = self.skill_registry.search(&args.query, limit);
162
163 if matches.is_empty() {
164 return Ok(ToolOutput::success(
165 "No matching skills found. Continue with the core tools.".to_string(),
166 ));
167 }
168
169 let mut lines = vec![format!(
170 "Found {} matching skill(s). Invoke one with Skill using its skill_name.",
171 matches.len()
172 )];
173 let metadata: Vec<_> = matches
174 .iter()
175 .map(|skill| {
176 let kind = format!("{:?}", skill.kind).to_lowercase();
177 let allowed_tools = skill.allowed_tools.as_deref().unwrap_or("not specified");
178 lines.push(format!(
179 "- {} ({kind}): {} Allowed tools: {}.",
180 skill.name, skill.description, allowed_tools
181 ));
182 serde_json::json!({
183 "name": skill.name,
184 "description": skill.description,
185 "kind": kind,
186 "tags": skill.tags,
187 "allowed_tools": skill.allowed_tools,
188 })
189 })
190 .collect();
191
192 Ok(ToolOutput {
193 content: lines.join("\n"),
194 success: true,
195 metadata: Some(serde_json::json!({ "skills": metadata })),
196 images: Vec::new(),
197 })
198 }
199}
200
201pub struct SkillTool {
203 skill_registry: Arc<SkillRegistry>,
204 llm_client: Arc<dyn LlmClient>,
205 tool_executor: Arc<ToolExecutor>,
206 base_config: AgentConfig,
207}
208
209impl SkillTool {
210 pub(crate) fn new(
211 skill_registry: Arc<SkillRegistry>,
212 llm_client: Arc<dyn LlmClient>,
213 tool_executor: Arc<ToolExecutor>,
214 base_config: AgentConfig,
215 ) -> Self {
216 Self {
217 skill_registry,
218 llm_client,
219 tool_executor,
220 base_config,
221 }
222 }
223
224 fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
226 let permissions = skill.parse_allowed_tools();
227
228 let mut allow_rules = Vec::new();
230 for perm in permissions {
231 let rule_str = if perm.pattern == "*" {
233 perm.tool.clone()
234 } else {
235 format!("{}({})", perm.tool, perm.pattern)
236 };
237 allow_rules.push(PermissionRule::new(&rule_str));
238 }
239
240 PermissionPolicy {
241 deny: Vec::new(),
242 allow: allow_rules,
243 ask: Vec::new(),
244 default_decision: PermissionDecision::Deny, enabled: true,
246 }
247 }
248}
249
250#[async_trait]
251impl Tool for SkillTool {
252 fn name(&self) -> &str {
253 "Skill"
254 }
255
256 fn description(&self) -> &str {
257 "Invoke a skill with temporary permission grants. \
258Use a JSON object with the canonical shape {\"skill_name\":\"<skill-name>\",\"prompt\":\"<optional prompt>\"}. \
259Always 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'. \
260The skill's allowed-tools are granted during execution and revoked after completion."
261 }
262
263 fn parameters(&self) -> Value {
264 serde_json::json!({
265 "type": "object",
266 "additionalProperties": false,
267 "properties": {
268 "skill_name": {
269 "type": "string",
270 "description": "Required. Canonical skill identifier to invoke. Always provide this exact field name: 'skill_name'."
271 },
272 "prompt": {
273 "type": "string",
274 "description": "Optional prompt or query to pass to the skill after it is loaded."
275 }
276 },
277 "required": ["skill_name"],
278 "examples": [
279 {
280 "skill_name": "code-review"
281 },
282 {
283 "skill_name": "code-review",
284 "prompt": "Review this patch for correctness and regressions."
285 }
286 ]
287 })
288 }
289
290 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
291 let args = SkillArgs::from_tool_args(args)?;
292
293 let skill = self
295 .skill_registry
296 .get(&args.skill_name)
297 .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
298
299 let skill_permission_policy = Self::create_skill_permission_policy(&skill);
301
302 let mut skill_config = self.base_config.clone();
304
305 skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
307
308 let temp_registry = Arc::new(SkillRegistry::new());
310 temp_registry.register(skill.clone())?;
311 skill_config.skill_registry = Some(temp_registry);
312
313 skill_config.prompt_slots.role = Some(format!(
315 "You are executing the '{}' skill.\n\n{}\n\n{}",
316 skill.name, skill.description, skill.content
317 ));
318
319 let agent_loop = AgentLoop::new(
321 self.llm_client.clone(),
322 self.tool_executor.clone(),
323 ctx.clone(),
324 skill_config,
325 );
326
327 let prompt = args
329 .prompt
330 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
331
332 let result = agent_loop.execute(&[], &prompt, None).await?;
334
335 Ok(ToolOutput {
337 content: result.text,
338 success: true,
339 metadata: Some(serde_json::json!({
340 "skill_name": skill.name,
341 "tool_calls": result.tool_calls_count,
342 "usage": result.usage,
343 })),
344 images: Vec::new(),
345 })
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::llm::{
353 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
354 };
355 use crate::skills::SkillKind;
356 use crate::tools::ToolContext;
357 use anyhow::Result;
358 use async_trait::async_trait;
359 use std::path::PathBuf;
360 use std::sync::Mutex;
361 use tokio::sync::mpsc;
362
363 struct MockLlmClient {
364 responses: Mutex<Vec<LlmResponse>>,
365 }
366
367 impl MockLlmClient {
368 fn new(responses: Vec<LlmResponse>) -> Self {
369 Self {
370 responses: Mutex::new(responses),
371 }
372 }
373
374 fn text_response(text: &str) -> LlmResponse {
375 LlmResponse {
376 message: Message {
377 role: "assistant".to_string(),
378 content: vec![ContentBlock::Text {
379 text: text.to_string(),
380 }],
381 reasoning_content: None,
382 },
383 usage: TokenUsage {
384 prompt_tokens: 10,
385 completion_tokens: 5,
386 total_tokens: 15,
387 cache_read_tokens: None,
388 cache_write_tokens: None,
389 },
390 stop_reason: Some("end_turn".to_string()),
391 meta: None,
392 }
393 }
394 }
395
396 #[async_trait]
397 impl LlmClient for MockLlmClient {
398 async fn complete(
399 &self,
400 _messages: &[Message],
401 _system: Option<&str>,
402 _tools: &[ToolDefinition],
403 ) -> Result<LlmResponse> {
404 let mut responses = self.responses.lock().unwrap();
405 if responses.is_empty() {
406 anyhow::bail!("No more mock responses available");
407 }
408 Ok(responses.remove(0))
409 }
410
411 async fn complete_streaming(
412 &self,
413 _messages: &[Message],
414 _system: Option<&str>,
415 _tools: &[ToolDefinition],
416 _cancel_token: tokio_util::sync::CancellationToken,
417 ) -> Result<mpsc::Receiver<StreamEvent>> {
418 anyhow::bail!("streaming not used in SkillTool tests")
419 }
420 }
421
422 #[test]
423 fn test_skill_permission_policy() {
424 let skill = Skill {
425 name: "test-skill".to_string(),
426 description: "Test".to_string(),
427 allowed_tools: Some("read(*), grep(*)".to_string()),
428 disable_model_invocation: false,
429 kind: SkillKind::Instruction,
430 content: String::new(),
431 tags: Vec::new(),
432 version: None,
433 };
434
435 let policy = SkillTool::create_skill_permission_policy(&skill);
436
437 assert_eq!(
439 policy.check("read", &serde_json::json!({})),
440 PermissionDecision::Allow
441 );
442 assert_eq!(
443 policy.check("grep", &serde_json::json!({})),
444 PermissionDecision::Allow
445 );
446
447 assert_eq!(
449 policy.check("write", &serde_json::json!({})),
450 PermissionDecision::Deny
451 );
452 }
453
454 #[test]
455 fn test_skill_args_accepts_documented_shape() {
456 let args =
457 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
458 assert_eq!(args.skill_name, "code-review");
459 assert_eq!(args.prompt, None);
460 }
461
462 #[test]
463 fn test_skill_args_accepts_common_aliases_and_wrappers() {
464 let camel =
465 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
466 assert_eq!(camel.skill_name, "code-review");
467
468 let name = SkillArgs::from_tool_args(&serde_json::json!({
469 "name": "code-review",
470 "query": "review this patch"
471 }))
472 .unwrap();
473 assert_eq!(name.skill_name, "code-review");
474 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
475
476 let nested = SkillArgs::from_tool_args(&serde_json::json!({
477 "input": {
478 "skill_name": "code-review",
479 "prompt": "review this patch"
480 }
481 }))
482 .unwrap();
483 assert_eq!(nested.skill_name, "code-review");
484 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
485
486 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
487 assert_eq!(direct.skill_name, "code-review");
488 }
489
490 #[test]
491 fn test_skill_args_missing_skill_name_errors() {
492 let err =
493 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
494 assert!(err.to_string().contains("missing field 'skill_name'"));
495 }
496
497 #[test]
498 fn test_search_skills_args_accepts_string_and_object() {
499 let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
500 assert_eq!(direct.query, "review code");
501 assert_eq!(direct.limit, None);
502
503 let object =
504 SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
505 .unwrap();
506 assert_eq!(object.query, "review");
507 assert_eq!(object.limit, Some(2));
508 }
509
510 #[tokio::test]
511 async fn test_search_skills_tool_returns_matching_skills() {
512 let registry = Arc::new(SkillRegistry::new());
513 registry.register_unchecked(Arc::new(Skill {
514 name: "code-review".to_string(),
515 description: "Review code changes".to_string(),
516 allowed_tools: Some("read(*), grep(*)".to_string()),
517 disable_model_invocation: false,
518 kind: SkillKind::Instruction,
519 content: "Review instructions".to_string(),
520 tags: vec!["review".to_string()],
521 version: None,
522 }));
523
524 let tool = SearchSkillsTool::new(registry);
525 let result = tool
526 .execute(
527 &serde_json::json!({"query": "review"}),
528 &ToolContext::new(PathBuf::from("/tmp")),
529 )
530 .await
531 .unwrap();
532
533 assert!(result.success);
534 assert!(result.content.contains("code-review"));
535 assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
536 }
537
538 #[test]
539 fn test_skill_tool_schema_enforces_canonical_shape() {
540 let registry = Arc::new(SkillRegistry::new());
541 let llm = Arc::new(MockLlmClient::new(vec![]));
542 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
543 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
544
545 let params = tool.parameters();
546 assert_eq!(params["type"], "object");
547 assert_eq!(params["additionalProperties"], serde_json::json!(false));
548 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
549
550 let examples = params["examples"].as_array().unwrap();
551 assert_eq!(examples[0]["skill_name"], "code-review");
552 assert!(examples[0].get("name").is_none());
553 assert!(examples[0].get("skillName").is_none());
554 }
555
556 #[tokio::test]
557 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
558 use crate::prompts::PlanningMode;
559
560 let registry = Arc::new(SkillRegistry::new());
561 registry.register_unchecked(Arc::new(Skill {
562 name: "test-skill".to_string(),
563 description: "Run a focused skill".to_string(),
564 allowed_tools: None,
565 disable_model_invocation: false,
566 kind: SkillKind::Instruction,
567 content: "Reply with the skill result.".to_string(),
568 tags: vec!["focus".to_string()],
569 version: None,
570 }));
571
572 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
573 "skill completed",
574 )]));
575 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
576 let config = AgentConfig {
578 planning_mode: PlanningMode::Disabled,
579 continuation_enabled: false,
580 ..Default::default()
581 };
582 let tool = SkillTool::new(registry, llm, executor, config);
583
584 let result = tool
585 .execute(
586 &serde_json::json!({
587 "skill_name": "test-skill",
588 "prompt": "verify the skill result"
589 }),
590 &ToolContext::new(PathBuf::from("/tmp")),
591 )
592 .await
593 .unwrap();
594
595 assert!(result.success);
596 assert_eq!(result.content, "skill completed");
597 let metadata = result.metadata.unwrap();
598 assert_eq!(metadata["skill_name"], "test-skill");
599 assert_eq!(metadata["tool_calls"], 0);
600 }
601
602 #[tokio::test]
603 async fn test_skill_tool_execute_errors_for_unknown_skill() {
604 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
605 "unused",
606 )]));
607 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
608 let tool = SkillTool::new(
609 Arc::new(SkillRegistry::new()),
610 llm,
611 executor,
612 AgentConfig::default(),
613 );
614
615 let err = tool
616 .execute(
617 &serde_json::json!({"skill_name": "missing-skill"}),
618 &ToolContext::new(PathBuf::from("/tmp")),
619 )
620 .await
621 .unwrap_err();
622
623 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
624 }
625}