Skip to main content

a3s_code_core/tools/
skill.rs

1//! Skill Tool - Invoke skills as callable tools with temporary permission grants
2//!
3//! This tool allows agents to invoke skills as first-class tools, with the skill's
4//! allowed-tools temporarily granted during execution. This enforces skill-based
5//! access patterns and prevents agents from bypassing skills to directly access
6//! underlying tools.
7//!
8//! ## Usage
9//!
10//! ```rust
11//! // Agent calls: Skill("data-processor")
12//! // The skill's allowed-tools are temporarily granted
13//! // After execution, permissions are restored
14//! ```
15
16use 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/// Arguments for the Skill tool
28#[derive(Debug, Serialize, Deserialize)]
29pub struct SkillArgs {
30    /// Name of the skill to invoke
31    pub skill_name: String,
32    /// Optional prompt/query to pass to the skill
33    #[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/// Arguments for the search_skills tool
80#[derive(Debug, Serialize, Deserialize)]
81pub struct SearchSkillsArgs {
82    /// Query describing the desired skill
83    pub query: String,
84    /// Maximum number of results to return
85    #[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
116/// Search available skills without injecting all skill descriptions into context.
117pub 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            error_kind: None,
198        })
199    }
200}
201
202/// Skill tool - invokes skills with temporary permission grants
203pub struct SkillTool {
204    skill_registry: Arc<SkillRegistry>,
205    llm_client: Arc<dyn LlmClient>,
206    tool_executor: Arc<ToolExecutor>,
207    base_config: AgentConfig,
208}
209
210impl SkillTool {
211    pub(crate) fn new(
212        skill_registry: Arc<SkillRegistry>,
213        llm_client: Arc<dyn LlmClient>,
214        tool_executor: Arc<ToolExecutor>,
215        base_config: AgentConfig,
216    ) -> Self {
217        Self {
218            skill_registry,
219            llm_client,
220            tool_executor,
221            base_config,
222        }
223    }
224
225    /// Create a temporary permission policy that grants the skill's allowed-tools
226    fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
227        let permissions = skill.parse_allowed_tools();
228
229        // Convert skill permissions to PermissionRules
230        let mut allow_rules = Vec::new();
231        for perm in permissions {
232            // Create a rule string in the format "Tool(pattern)"
233            let rule_str = if perm.pattern == "*" {
234                perm.tool.clone()
235            } else {
236                format!("{}({})", perm.tool, perm.pattern)
237            };
238            allow_rules.push(PermissionRule::new(&rule_str));
239        }
240
241        PermissionPolicy {
242            deny: Vec::new(),
243            allow: allow_rules,
244            ask: Vec::new(),
245            default_decision: PermissionDecision::Deny, // Deny by default - only allow what skill specifies
246            enabled: true,
247        }
248    }
249}
250
251#[async_trait]
252impl Tool for SkillTool {
253    fn name(&self) -> &str {
254        "Skill"
255    }
256
257    fn description(&self) -> &str {
258        "Invoke a skill with temporary permission grants. \
259Use a JSON object with the canonical shape {\"skill_name\":\"<skill-name>\",\"prompt\":\"<optional prompt>\"}. \
260Always 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'. \
261The skill's allowed-tools are granted during execution and revoked after completion."
262    }
263
264    fn parameters(&self) -> Value {
265        serde_json::json!({
266            "type": "object",
267            "additionalProperties": false,
268            "properties": {
269                "skill_name": {
270                    "type": "string",
271                    "description": "Required. Canonical skill identifier to invoke. Always provide this exact field name: 'skill_name'."
272                },
273                "prompt": {
274                    "type": "string",
275                    "description": "Optional prompt or query to pass to the skill after it is loaded."
276                }
277            },
278            "required": ["skill_name"],
279            "examples": [
280                {
281                    "skill_name": "code-review"
282                },
283                {
284                    "skill_name": "code-review",
285                    "prompt": "Review this patch for correctness and regressions."
286                }
287            ]
288        })
289    }
290
291    async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
292        let args = SkillArgs::from_tool_args(args)?;
293
294        // Get the skill
295        let skill = self
296            .skill_registry
297            .get(&args.skill_name)
298            .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
299
300        // Create temporary permission policy with skill's allowed-tools
301        let skill_permission_policy = Self::create_skill_permission_policy(&skill);
302
303        // Create a modified config with the skill's permissions
304        let mut skill_config = self.base_config.clone();
305
306        // Set the skill's permission policy as the permission checker
307        skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
308
309        // Create a temporary skill registry with only this skill
310        let temp_registry = Arc::new(SkillRegistry::new());
311        temp_registry.register(skill.clone())?;
312        skill_config.skill_registry = Some(temp_registry);
313
314        // Build the system prompt with skill content
315        skill_config.prompt_slots.role = Some(format!(
316            "You are executing the '{}' skill.\n\n{}\n\n{}",
317            skill.name, skill.description, skill.content
318        ));
319
320        // Create agent loop with skill permissions
321        let agent_loop = AgentLoop::new(
322            self.llm_client.clone(),
323            self.tool_executor.clone(),
324            ctx.clone(),
325            skill_config,
326        );
327
328        // Execute the skill with the prompt
329        let prompt = args
330            .prompt
331            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
332
333        // Execute the agent loop with skill permissions
334        let result = agent_loop.execute(&[], &prompt, None).await?;
335
336        // Return the final response as tool output
337        Ok(ToolOutput {
338            content: result.text,
339            success: true,
340            metadata: Some(serde_json::json!({
341                "skill_name": skill.name,
342                "tool_calls": result.tool_calls_count,
343                "usage": result.usage,
344            })),
345            images: Vec::new(),
346            error_kind: None,
347        })
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::llm::{
355        ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
356    };
357    use crate::skills::SkillKind;
358    use crate::tools::ToolContext;
359    use anyhow::Result;
360    use async_trait::async_trait;
361    use std::path::PathBuf;
362    use std::sync::Mutex;
363    use tokio::sync::mpsc;
364
365    struct MockLlmClient {
366        responses: Mutex<Vec<LlmResponse>>,
367    }
368
369    impl MockLlmClient {
370        fn new(responses: Vec<LlmResponse>) -> Self {
371            Self {
372                responses: Mutex::new(responses),
373            }
374        }
375
376        fn text_response(text: &str) -> LlmResponse {
377            LlmResponse {
378                message: Message {
379                    role: "assistant".to_string(),
380                    content: vec![ContentBlock::Text {
381                        text: text.to_string(),
382                    }],
383                    reasoning_content: None,
384                },
385                usage: TokenUsage {
386                    prompt_tokens: 10,
387                    completion_tokens: 5,
388                    total_tokens: 15,
389                    cache_read_tokens: None,
390                    cache_write_tokens: None,
391                },
392                stop_reason: Some("end_turn".to_string()),
393                meta: None,
394            }
395        }
396    }
397
398    #[async_trait]
399    impl LlmClient for MockLlmClient {
400        async fn complete(
401            &self,
402            _messages: &[Message],
403            _system: Option<&str>,
404            _tools: &[ToolDefinition],
405        ) -> Result<LlmResponse> {
406            let mut responses = self.responses.lock().unwrap();
407            if responses.is_empty() {
408                anyhow::bail!("No more mock responses available");
409            }
410            Ok(responses.remove(0))
411        }
412
413        async fn complete_streaming(
414            &self,
415            _messages: &[Message],
416            _system: Option<&str>,
417            _tools: &[ToolDefinition],
418            _cancel_token: tokio_util::sync::CancellationToken,
419        ) -> Result<mpsc::Receiver<StreamEvent>> {
420            anyhow::bail!("streaming not used in SkillTool tests")
421        }
422    }
423
424    #[test]
425    fn test_skill_permission_policy() {
426        let skill = Skill {
427            name: "test-skill".to_string(),
428            description: "Test".to_string(),
429            allowed_tools: Some("read(*), grep(*)".to_string()),
430            disable_model_invocation: false,
431            kind: SkillKind::Instruction,
432            content: String::new(),
433            tags: Vec::new(),
434            version: None,
435        };
436
437        let policy = SkillTool::create_skill_permission_policy(&skill);
438
439        // Should allow tools in allowed-tools
440        assert_eq!(
441            policy.check("read", &serde_json::json!({})),
442            PermissionDecision::Allow
443        );
444        assert_eq!(
445            policy.check("grep", &serde_json::json!({})),
446            PermissionDecision::Allow
447        );
448
449        // Should deny tools not in allowed-tools
450        assert_eq!(
451            policy.check("write", &serde_json::json!({})),
452            PermissionDecision::Deny
453        );
454    }
455
456    #[test]
457    fn test_skill_args_accepts_documented_shape() {
458        let args =
459            SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
460        assert_eq!(args.skill_name, "code-review");
461        assert_eq!(args.prompt, None);
462    }
463
464    #[test]
465    fn test_skill_args_accepts_common_aliases_and_wrappers() {
466        let camel =
467            SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
468        assert_eq!(camel.skill_name, "code-review");
469
470        let name = SkillArgs::from_tool_args(&serde_json::json!({
471            "name": "code-review",
472            "query": "review this patch"
473        }))
474        .unwrap();
475        assert_eq!(name.skill_name, "code-review");
476        assert_eq!(name.prompt.as_deref(), Some("review this patch"));
477
478        let nested = SkillArgs::from_tool_args(&serde_json::json!({
479            "input": {
480                "skill_name": "code-review",
481                "prompt": "review this patch"
482            }
483        }))
484        .unwrap();
485        assert_eq!(nested.skill_name, "code-review");
486        assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
487
488        let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
489        assert_eq!(direct.skill_name, "code-review");
490    }
491
492    #[test]
493    fn test_skill_args_missing_skill_name_errors() {
494        let err =
495            SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
496        assert!(err.to_string().contains("missing field 'skill_name'"));
497    }
498
499    #[test]
500    fn test_search_skills_args_accepts_string_and_object() {
501        let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
502        assert_eq!(direct.query, "review code");
503        assert_eq!(direct.limit, None);
504
505        let object =
506            SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
507                .unwrap();
508        assert_eq!(object.query, "review");
509        assert_eq!(object.limit, Some(2));
510    }
511
512    #[tokio::test]
513    async fn test_search_skills_tool_returns_matching_skills() {
514        let registry = Arc::new(SkillRegistry::new());
515        registry.register_unchecked(Arc::new(Skill {
516            name: "code-review".to_string(),
517            description: "Review code changes".to_string(),
518            allowed_tools: Some("read(*), grep(*)".to_string()),
519            disable_model_invocation: false,
520            kind: SkillKind::Instruction,
521            content: "Review instructions".to_string(),
522            tags: vec!["review".to_string()],
523            version: None,
524        }));
525
526        let tool = SearchSkillsTool::new(registry);
527        let result = tool
528            .execute(
529                &serde_json::json!({"query": "review"}),
530                &ToolContext::new(PathBuf::from("/tmp")),
531            )
532            .await
533            .unwrap();
534
535        assert!(result.success);
536        assert!(result.content.contains("code-review"));
537        assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
538    }
539
540    #[test]
541    fn test_skill_tool_schema_enforces_canonical_shape() {
542        let registry = Arc::new(SkillRegistry::new());
543        let llm = Arc::new(MockLlmClient::new(vec![]));
544        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
545        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
546
547        let params = tool.parameters();
548        assert_eq!(params["type"], "object");
549        assert_eq!(params["additionalProperties"], serde_json::json!(false));
550        assert_eq!(params["required"], serde_json::json!(["skill_name"]));
551
552        let examples = params["examples"].as_array().unwrap();
553        assert_eq!(examples[0]["skill_name"], "code-review");
554        assert!(examples[0].get("name").is_none());
555        assert!(examples[0].get("skillName").is_none());
556    }
557
558    #[tokio::test]
559    async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
560        use crate::prompts::PlanningMode;
561
562        let registry = Arc::new(SkillRegistry::new());
563        registry.register_unchecked(Arc::new(Skill {
564            name: "test-skill".to_string(),
565            description: "Run a focused skill".to_string(),
566            allowed_tools: None,
567            disable_model_invocation: false,
568            kind: SkillKind::Instruction,
569            content: "Reply with the skill result.".to_string(),
570            tags: vec!["focus".to_string()],
571            version: None,
572        }));
573
574        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
575            "skill completed",
576        )]));
577        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
578        // Disable planning mode since the mock only has one response
579        let config = AgentConfig {
580            planning_mode: PlanningMode::Disabled,
581            continuation_enabled: false,
582            ..Default::default()
583        };
584        let tool = SkillTool::new(registry, llm, executor, config);
585
586        let result = tool
587            .execute(
588                &serde_json::json!({
589                    "skill_name": "test-skill",
590                    "prompt": "verify the skill result"
591                }),
592                &ToolContext::new(PathBuf::from("/tmp")),
593            )
594            .await
595            .unwrap();
596
597        assert!(result.success);
598        assert_eq!(result.content, "skill completed");
599        let metadata = result.metadata.unwrap();
600        assert_eq!(metadata["skill_name"], "test-skill");
601        assert_eq!(metadata["tool_calls"], 0);
602    }
603
604    #[tokio::test]
605    async fn test_skill_tool_execute_errors_for_unknown_skill() {
606        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
607            "unused",
608        )]));
609        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
610        let tool = SkillTool::new(
611            Arc::new(SkillRegistry::new()),
612            llm,
613            executor,
614            AgentConfig::default(),
615        );
616
617        let err = tool
618            .execute(
619                &serde_json::json!({"skill_name": "missing-skill"}),
620                &ToolContext::new(PathBuf::from("/tmp")),
621            )
622            .await
623            .unwrap_err();
624
625        assert!(err.to_string().contains("Skill 'missing-skill' not found"));
626    }
627}