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/// Skill tool - invokes skills with temporary permission grants
80pub 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    /// Create a temporary permission policy that grants the skill's allowed-tools
103    fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
104        let permissions = skill.parse_allowed_tools();
105
106        // Convert skill permissions to PermissionRules
107        let mut allow_rules = Vec::new();
108        for perm in permissions {
109            // Create a rule string in the format "Tool(pattern)"
110            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, // Deny by default - only allow what skill specifies
123            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        // Get the skill
172        let skill = self
173            .skill_registry
174            .get(&args.skill_name)
175            .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
176
177        // Create temporary permission policy with skill's allowed-tools
178        let skill_permission_policy = Self::create_skill_permission_policy(&skill);
179
180        // Create a modified config with the skill's permissions
181        let mut skill_config = self.base_config.clone();
182
183        // Set the skill's permission policy as the permission checker
184        skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
185
186        // Create a temporary skill registry with only this skill
187        let temp_registry = Arc::new(SkillRegistry::new());
188        temp_registry.register(skill.clone())?;
189        skill_config.skill_registry = Some(temp_registry);
190
191        // Build the system prompt with skill content
192        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        // Create agent loop with skill permissions
198        let agent_loop = AgentLoop::new(
199            self.llm_client.clone(),
200            self.tool_executor.clone(),
201            ctx.clone(),
202            skill_config,
203        );
204
205        // Execute the skill with the prompt
206        let prompt = args
207            .prompt
208            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
209
210        // Execute the agent loop with skill permissions
211        let result = agent_loop.execute(&[], &prompt, None).await?;
212
213        // Return the final response as tool output
214        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        // Should allow tools in allowed-tools
315        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        // Should deny tools not in allowed-tools
325        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}