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            _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        // Should allow tools in allowed-tools
316        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        // Should deny tools not in allowed-tools
326        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        // Disable planning mode since the mock only has one response
414        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}