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//! ```text
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        if permissions.is_empty() {
230            tracing::warn!(
231                skill = %skill.name,
232                "Skill has no allowed-tools grants; Skill invocation remains fail-secure and will deny tool use"
233            );
234            return PermissionPolicy {
235                deny: Vec::new(),
236                allow: Vec::new(),
237                ask: Vec::new(),
238                default_decision: PermissionDecision::Deny,
239                enabled: true,
240            };
241        }
242
243        // Convert skill permissions to PermissionRules
244        let mut allow_rules = Vec::new();
245        for perm in permissions {
246            // Create a rule string in the format "Tool(pattern)"
247            let rule_str = if perm.pattern == "*" {
248                perm.tool.clone()
249            } else {
250                format!("{}({})", perm.tool, perm.pattern)
251            };
252            allow_rules.push(PermissionRule::new(&rule_str));
253        }
254
255        PermissionPolicy {
256            deny: Vec::new(),
257            allow: allow_rules,
258            ask: Vec::new(),
259            default_decision: PermissionDecision::Deny, // Deny by default - only allow what skill specifies
260            enabled: true,
261        }
262    }
263}
264
265#[async_trait]
266impl Tool for SkillTool {
267    fn name(&self) -> &str {
268        "Skill"
269    }
270
271    fn description(&self) -> &str {
272        "Invoke a skill with temporary permission grants. \
273Use a JSON object with the canonical shape {\"skill_name\":\"<skill-name>\",\"prompt\":\"<optional prompt>\"}. \
274Always 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'. \
275The skill's allowed-tools are granted during execution and revoked after completion."
276    }
277
278    fn parameters(&self) -> Value {
279        serde_json::json!({
280            "type": "object",
281            "additionalProperties": false,
282            "properties": {
283                "skill_name": {
284                    "type": "string",
285                    "description": "Required. Canonical skill identifier to invoke. Always provide this exact field name: 'skill_name'."
286                },
287                "prompt": {
288                    "type": "string",
289                    "description": "Optional prompt or query to pass to the skill after it is loaded."
290                }
291            },
292            "required": ["skill_name"],
293            "examples": [
294                {
295                    "skill_name": "code-review"
296                },
297                {
298                    "skill_name": "code-review",
299                    "prompt": "Review this patch for correctness and regressions."
300                }
301            ]
302        })
303    }
304
305    async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
306        let args = SkillArgs::from_tool_args(args)?;
307
308        // Get the skill
309        let skill = self
310            .skill_registry
311            .get(&args.skill_name)
312            .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
313
314        // Create temporary permission policy with skill's allowed-tools
315        let skill_permission_policy = Self::create_skill_permission_policy(&skill);
316
317        // Create a modified config with the skill's permissions
318        let mut skill_config = self.base_config.clone();
319
320        // Set the skill's permission policy as the permission checker
321        skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
322        skill_config.enforce_active_skill_tool_restrictions = true;
323
324        // Create a temporary skill registry with only this skill
325        let temp_registry = Arc::new(SkillRegistry::new());
326        temp_registry.register(skill.clone())?;
327        skill_config.skill_registry = Some(temp_registry);
328
329        // Build the system prompt with skill content
330        skill_config.prompt_slots.role = Some(format!(
331            "You are executing the '{}' skill.\n\n{}\n\n{}",
332            skill.name, skill.description, skill.content
333        ));
334
335        // Create agent loop with skill permissions
336        let agent_loop = AgentLoop::new(
337            self.llm_client.clone(),
338            self.tool_executor.clone(),
339            ctx.clone(),
340            skill_config,
341        );
342
343        // Execute the skill with the prompt
344        let prompt = args
345            .prompt
346            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
347
348        // The skill is a child run, but it remains inside the owning run's
349        // cancellation and budget scope. The child AgentLoop wraps the raw
350        // provider with its own scoped LLM invoker, while the inherited token
351        // ensures parent cancellation reaches every provider/tool call.
352        let cancellation = ctx.cancellation_token();
353        let result = agent_loop
354            .execute_with_session(
355                &[],
356                &prompt,
357                ctx.session_id.as_deref(),
358                None,
359                Some(&cancellation),
360            )
361            .await?;
362        if cancellation.is_cancelled() {
363            anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
364        }
365
366        // Return the final response as tool output
367        Ok(ToolOutput {
368            content: result.text,
369            success: true,
370            metadata: Some(serde_json::json!({
371                "skill_name": skill.name,
372                "tool_calls": result.tool_calls_count,
373                "usage": result.usage,
374            })),
375            images: Vec::new(),
376            error_kind: None,
377        })
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::budget::{BudgetDecision, BudgetGuard};
385    use crate::llm::{
386        ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
387    };
388    use crate::skills::SkillKind;
389    use crate::tools::ToolContext;
390    use anyhow::Result;
391    use async_trait::async_trait;
392    use std::path::PathBuf;
393    use std::sync::atomic::{AtomicUsize, Ordering};
394    use std::sync::Mutex;
395    use std::time::Duration;
396    use tokio::sync::{mpsc, Notify};
397    use tokio_util::sync::CancellationToken;
398
399    struct MockLlmClient {
400        responses: Mutex<Vec<LlmResponse>>,
401    }
402
403    impl MockLlmClient {
404        fn new(responses: Vec<LlmResponse>) -> Self {
405            Self {
406                responses: Mutex::new(responses),
407            }
408        }
409
410        fn text_response(text: &str) -> LlmResponse {
411            LlmResponse {
412                message: Message {
413                    role: "assistant".to_string(),
414                    content: vec![ContentBlock::Text {
415                        text: text.to_string(),
416                    }],
417                    reasoning_content: None,
418                },
419                usage: TokenUsage {
420                    prompt_tokens: 10,
421                    completion_tokens: 5,
422                    total_tokens: 15,
423                    cache_read_tokens: None,
424                    cache_write_tokens: None,
425                },
426                stop_reason: Some("end_turn".to_string()),
427                token_logprobs: Vec::new(),
428                meta: None,
429            }
430        }
431    }
432
433    #[async_trait]
434    impl LlmClient for MockLlmClient {
435        async fn complete(
436            &self,
437            _messages: &[Message],
438            _system: Option<&str>,
439            _tools: &[ToolDefinition],
440        ) -> Result<LlmResponse> {
441            let mut responses = self.responses.lock().unwrap();
442            if responses.is_empty() {
443                anyhow::bail!("No more mock responses available");
444            }
445            Ok(responses.remove(0))
446        }
447
448        async fn complete_streaming(
449            &self,
450            _messages: &[Message],
451            _system: Option<&str>,
452            _tools: &[ToolDefinition],
453            _cancel_token: tokio_util::sync::CancellationToken,
454        ) -> Result<mpsc::Receiver<StreamEvent>> {
455            anyhow::bail!("streaming not used in SkillTool tests")
456        }
457    }
458
459    #[derive(Default)]
460    struct SkillBudgetGuard {
461        checks: AtomicUsize,
462        records: AtomicUsize,
463        sessions: Mutex<Vec<String>>,
464    }
465
466    #[async_trait]
467    impl BudgetGuard for SkillBudgetGuard {
468        async fn check_before_llm(
469            &self,
470            session_id: &str,
471            _estimated_prompt_tokens: usize,
472        ) -> BudgetDecision {
473            self.checks.fetch_add(1, Ordering::SeqCst);
474            self.sessions.lock().unwrap().push(session_id.to_string());
475            BudgetDecision::Allow
476        }
477
478        async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
479            self.records.fetch_add(1, Ordering::SeqCst);
480        }
481    }
482
483    struct BlockingSkillClient {
484        started: Arc<Notify>,
485        calls: Arc<AtomicUsize>,
486    }
487
488    #[async_trait]
489    impl LlmClient for BlockingSkillClient {
490        async fn complete(
491            &self,
492            _messages: &[Message],
493            _system: Option<&str>,
494            _tools: &[ToolDefinition],
495        ) -> Result<LlmResponse> {
496            self.calls.fetch_add(1, Ordering::SeqCst);
497            self.started.notify_one();
498            std::future::pending::<Result<LlmResponse>>().await
499        }
500
501        async fn complete_streaming(
502            &self,
503            _messages: &[Message],
504            _system: Option<&str>,
505            _tools: &[ToolDefinition],
506            _cancel_token: CancellationToken,
507        ) -> Result<mpsc::Receiver<StreamEvent>> {
508            anyhow::bail!("streaming not used in SkillTool cancellation tests")
509        }
510    }
511
512    fn test_skill_registry() -> Arc<SkillRegistry> {
513        let registry = Arc::new(SkillRegistry::new());
514        registry.register_unchecked(Arc::new(Skill {
515            name: "test-skill".to_string(),
516            description: "Run a focused skill".to_string(),
517            allowed_tools: None,
518            disable_model_invocation: false,
519            kind: SkillKind::Instruction,
520            content: "Reply with the skill result.".to_string(),
521            tags: vec!["focus".to_string()],
522            version: None,
523        }));
524        registry
525    }
526
527    #[test]
528    fn test_skill_permission_policy() {
529        let skill = Skill {
530            name: "test-skill".to_string(),
531            description: "Test".to_string(),
532            allowed_tools: Some("read(*), grep(*)".to_string()),
533            disable_model_invocation: false,
534            kind: SkillKind::Instruction,
535            content: String::new(),
536            tags: Vec::new(),
537            version: None,
538        };
539
540        let policy = SkillTool::create_skill_permission_policy(&skill);
541
542        // Should allow tools in allowed-tools
543        assert_eq!(
544            policy.check("read", &serde_json::json!({})),
545            PermissionDecision::Allow
546        );
547        assert_eq!(
548            policy.check("grep", &serde_json::json!({})),
549            PermissionDecision::Allow
550        );
551
552        // Should deny tools not in allowed-tools
553        assert_eq!(
554            policy.check("write", &serde_json::json!({})),
555            PermissionDecision::Deny
556        );
557    }
558
559    #[test]
560    fn test_skill_permission_policy_denies_when_unspecified() {
561        let skill = Skill {
562            name: "test-skill".to_string(),
563            description: "Test".to_string(),
564            allowed_tools: None,
565            disable_model_invocation: false,
566            kind: SkillKind::Instruction,
567            content: String::new(),
568            tags: Vec::new(),
569            version: None,
570        };
571
572        let policy = SkillTool::create_skill_permission_policy(&skill);
573
574        assert_eq!(
575            policy.check("bash", &serde_json::json!({"command": "python --version"})),
576            PermissionDecision::Deny
577        );
578        assert_eq!(
579            policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
580            PermissionDecision::Deny
581        );
582    }
583
584    #[test]
585    fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
586        let skill = Skill {
587            name: "test-skill".to_string(),
588            description: "Test".to_string(),
589            allowed_tools: Some("Read Write Edit Bash".to_string()),
590            disable_model_invocation: false,
591            kind: SkillKind::Instruction,
592            content: String::new(),
593            tags: Vec::new(),
594            version: None,
595        };
596
597        let policy = SkillTool::create_skill_permission_policy(&skill);
598
599        assert_eq!(
600            policy.check("bash", &serde_json::json!({"command": "python --version"})),
601            PermissionDecision::Allow
602        );
603        assert_eq!(
604            policy.check("grep", &serde_json::json!({"pattern": "x"})),
605            PermissionDecision::Deny
606        );
607    }
608
609    #[test]
610    fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
611        let skill = Skill {
612            name: "test-skill".to_string(),
613            description: "Test".to_string(),
614            allowed_tools: Some("*".to_string()),
615            disable_model_invocation: false,
616            kind: SkillKind::Instruction,
617            content: String::new(),
618            tags: Vec::new(),
619            version: None,
620        };
621
622        let policy = SkillTool::create_skill_permission_policy(&skill);
623
624        assert_eq!(
625            policy.check("bash", &serde_json::json!({"command": "python --version"})),
626            PermissionDecision::Allow
627        );
628        assert_eq!(
629            policy.check("parallel_task", &serde_json::json!({"tasks": []})),
630            PermissionDecision::Allow
631        );
632    }
633
634    #[test]
635    fn test_skill_args_accepts_documented_shape() {
636        let args =
637            SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
638        assert_eq!(args.skill_name, "code-review");
639        assert_eq!(args.prompt, None);
640    }
641
642    #[test]
643    fn test_skill_args_accepts_common_aliases_and_wrappers() {
644        let camel =
645            SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
646        assert_eq!(camel.skill_name, "code-review");
647
648        let name = SkillArgs::from_tool_args(&serde_json::json!({
649            "name": "code-review",
650            "query": "review this patch"
651        }))
652        .unwrap();
653        assert_eq!(name.skill_name, "code-review");
654        assert_eq!(name.prompt.as_deref(), Some("review this patch"));
655
656        let nested = SkillArgs::from_tool_args(&serde_json::json!({
657            "input": {
658                "skill_name": "code-review",
659                "prompt": "review this patch"
660            }
661        }))
662        .unwrap();
663        assert_eq!(nested.skill_name, "code-review");
664        assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
665
666        let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
667        assert_eq!(direct.skill_name, "code-review");
668    }
669
670    #[test]
671    fn test_skill_args_missing_skill_name_errors() {
672        let err =
673            SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
674        assert!(err.to_string().contains("missing field 'skill_name'"));
675    }
676
677    #[test]
678    fn test_search_skills_args_accepts_string_and_object() {
679        let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
680        assert_eq!(direct.query, "review code");
681        assert_eq!(direct.limit, None);
682
683        let object =
684            SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
685                .unwrap();
686        assert_eq!(object.query, "review");
687        assert_eq!(object.limit, Some(2));
688    }
689
690    #[tokio::test]
691    async fn test_search_skills_tool_returns_matching_skills() {
692        let registry = Arc::new(SkillRegistry::new());
693        registry.register_unchecked(Arc::new(Skill {
694            name: "code-review".to_string(),
695            description: "Review code changes".to_string(),
696            allowed_tools: Some("read(*), grep(*)".to_string()),
697            disable_model_invocation: false,
698            kind: SkillKind::Instruction,
699            content: "Review instructions".to_string(),
700            tags: vec!["review".to_string()],
701            version: None,
702        }));
703
704        let tool = SearchSkillsTool::new(registry);
705        let result = tool
706            .execute(
707                &serde_json::json!({"query": "review"}),
708                &ToolContext::new(PathBuf::from("/tmp")),
709            )
710            .await
711            .unwrap();
712
713        assert!(result.success);
714        assert!(result.content.contains("code-review"));
715        assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
716    }
717
718    #[tokio::test]
719    async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
720        let registry = Arc::new(SkillRegistry::new());
721        for index in 0..25 {
722            registry.register_unchecked(Arc::new(Skill {
723                name: format!("review-{index:02}"),
724                description: "Review code changes".to_string(),
725                allowed_tools: Some("read(*)".to_string()),
726                disable_model_invocation: false,
727                kind: SkillKind::Instruction,
728                content: "Review instructions".to_string(),
729                tags: vec!["review".to_string()],
730                version: None,
731            }));
732        }
733        registry.register_unchecked(Arc::new(Skill {
734            name: "review-persona".to_string(),
735            description: "Review persona".to_string(),
736            allowed_tools: None,
737            disable_model_invocation: false,
738            kind: SkillKind::Persona,
739            content: "Persona instructions".to_string(),
740            tags: vec!["review".to_string()],
741            version: None,
742        }));
743
744        let tool = SearchSkillsTool::new(registry);
745        let result = tool
746            .execute(
747                &serde_json::json!({"query": "review", "limit": 100}),
748                &ToolContext::new(PathBuf::from("/tmp")),
749            )
750            .await
751            .unwrap();
752
753        let metadata = result.metadata.unwrap();
754        let skills = metadata["skills"].as_array().unwrap();
755        assert_eq!(skills.len(), 20);
756        assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
757    }
758
759    #[test]
760    fn test_skill_tool_schema_enforces_canonical_shape() {
761        let registry = Arc::new(SkillRegistry::new());
762        let llm = Arc::new(MockLlmClient::new(vec![]));
763        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
764        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
765
766        let params = tool.parameters();
767        assert_eq!(params["type"], "object");
768        assert_eq!(params["additionalProperties"], serde_json::json!(false));
769        assert_eq!(params["required"], serde_json::json!(["skill_name"]));
770
771        let examples = params["examples"].as_array().unwrap();
772        assert_eq!(examples[0]["skill_name"], "code-review");
773        assert!(examples[0].get("name").is_none());
774        assert!(examples[0].get("skillName").is_none());
775    }
776
777    #[tokio::test]
778    async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
779        use crate::prompts::PlanningMode;
780
781        let registry = Arc::new(SkillRegistry::new());
782        registry.register_unchecked(Arc::new(Skill {
783            name: "test-skill".to_string(),
784            description: "Run a focused skill".to_string(),
785            allowed_tools: None,
786            disable_model_invocation: false,
787            kind: SkillKind::Instruction,
788            content: "Reply with the skill result.".to_string(),
789            tags: vec!["focus".to_string()],
790            version: None,
791        }));
792
793        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
794            "skill completed",
795        )]));
796        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
797        // Disable planning mode since the mock only has one response
798        let config = AgentConfig {
799            planning_mode: PlanningMode::Disabled,
800            continuation_enabled: false,
801            ..Default::default()
802        };
803        let tool = SkillTool::new(registry, llm, executor, config);
804
805        let result = tool
806            .execute(
807                &serde_json::json!({
808                    "skill_name": "test-skill",
809                    "prompt": "verify the skill result"
810                }),
811                &ToolContext::new(PathBuf::from("/tmp")),
812            )
813            .await
814            .unwrap();
815
816        assert!(result.success);
817        assert_eq!(result.content, "skill completed");
818        let metadata = result.metadata.unwrap();
819        assert_eq!(metadata["skill_name"], "test-skill");
820        assert_eq!(metadata["tool_calls"], 0);
821    }
822
823    #[tokio::test]
824    async fn skill_child_llm_call_uses_parent_session_budget_scope() {
825        use crate::prompts::PlanningMode;
826
827        let guard = Arc::new(SkillBudgetGuard::default());
828        let config = AgentConfig {
829            planning_mode: PlanningMode::Disabled,
830            continuation_enabled: false,
831            budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
832            ..Default::default()
833        };
834        let tool = SkillTool::new(
835            test_skill_registry(),
836            Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
837                "skill completed",
838            )])),
839            Arc::new(ToolExecutor::new("/tmp".to_string())),
840            config,
841        );
842        let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
843
844        let result = tool
845            .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
846            .await
847            .unwrap();
848
849        assert!(result.success);
850        assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
851        assert_eq!(guard.records.load(Ordering::SeqCst), 1);
852        assert_eq!(
853            guard.sessions.lock().unwrap().as_slice(),
854            &["parent-session".to_string()]
855        );
856    }
857
858    #[tokio::test]
859    async fn skill_child_llm_call_stops_on_parent_cancellation() {
860        use crate::prompts::PlanningMode;
861
862        let started = Arc::new(Notify::new());
863        let calls = Arc::new(AtomicUsize::new(0));
864        let tool = SkillTool::new(
865            test_skill_registry(),
866            Arc::new(BlockingSkillClient {
867                started: Arc::clone(&started),
868                calls: Arc::clone(&calls),
869            }),
870            Arc::new(ToolExecutor::new("/tmp".to_string())),
871            AgentConfig {
872                planning_mode: PlanningMode::Disabled,
873                continuation_enabled: false,
874                ..Default::default()
875            },
876        );
877        let cancellation = CancellationToken::new();
878        let ctx = ToolContext::new(PathBuf::from("/tmp"))
879            .with_session_id("parent-session")
880            .with_cancellation(cancellation.clone());
881        let started_wait = started.notified();
882        let run = tokio::spawn(async move {
883            tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
884                .await
885        });
886
887        tokio::time::timeout(Duration::from_secs(1), started_wait)
888            .await
889            .expect("skill provider call should start");
890        cancellation.cancel();
891        let error = tokio::time::timeout(Duration::from_secs(1), run)
892            .await
893            .expect("parent cancellation must stop the skill child")
894            .expect("skill join should succeed")
895            .expect_err("cancelled skill must not return success");
896
897        assert!(error.to_string().contains("cancelled"));
898        assert_eq!(calls.load(Ordering::SeqCst), 1);
899    }
900
901    #[tokio::test]
902    async fn test_skill_tool_execute_errors_for_unknown_skill() {
903        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
904            "unused",
905        )]));
906        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
907        let tool = SkillTool::new(
908            Arc::new(SkillRegistry::new()),
909            llm,
910            executor,
911            AgentConfig::default(),
912        );
913
914        let err = tool
915            .execute(
916                &serde_json::json!({"skill_name": "missing-skill"}),
917                &ToolContext::new(PathBuf::from("/tmp")),
918            )
919            .await
920            .unwrap_err();
921
922        assert!(err.to_string().contains("Skill 'missing-skill' not found"));
923    }
924}