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        if ctx.has_run_governance() {
320            skill_config.permission_checker = ctx.run_permission_checker();
321            skill_config.confirmation_manager = ctx.run_confirmation_manager();
322        }
323
324        // A skill narrows the tools it may use; it must not replace the host
325        // boundary. Compose both checkers so TUI mode decisions, sandbox
326        // availability, and explicit escalation denials remain authoritative
327        // inside the skill child run.
328        let parent_checker = skill_config.permission_checker.take();
329        let parent_policy = skill_config.permission_policy.clone();
330        skill_config.permission_checker = Some(crate::child_run::compose_permission_checker(
331            Arc::new(skill_permission_policy.clone()),
332            Some(skill_permission_policy.clone()),
333            parent_checker,
334            parent_policy,
335        ));
336        skill_config.permission_policy = Some(skill_permission_policy);
337        skill_config.enforce_active_skill_tool_restrictions = true;
338
339        // Create a temporary skill registry with only this skill
340        let temp_registry = Arc::new(SkillRegistry::new());
341        temp_registry.register(skill.clone())?;
342        skill_config.skill_registry = Some(temp_registry);
343
344        // Build the system prompt with skill content
345        skill_config.prompt_slots.role = Some(format!(
346            "You are executing the '{}' skill.\n\n{}\n\n{}",
347            skill.name, skill.description, skill.content
348        ));
349
350        // Create agent loop with skill permissions
351        let agent_loop = AgentLoop::new(
352            self.llm_client.clone(),
353            self.tool_executor.clone(),
354            ctx.clone(),
355            skill_config,
356        );
357
358        // Execute the skill with the prompt
359        let prompt = args
360            .prompt
361            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
362
363        // The skill is a child run, but it remains inside the owning run's
364        // cancellation and budget scope. The child AgentLoop wraps the raw
365        // provider with its own scoped LLM invoker, while the inherited token
366        // ensures parent cancellation reaches every provider/tool call.
367        let cancellation = ctx.cancellation_token();
368        let result = agent_loop
369            .execute_with_session(
370                &[],
371                &prompt,
372                ctx.session_id.as_deref(),
373                None,
374                Some(&cancellation),
375            )
376            .await?;
377        if cancellation.is_cancelled() {
378            anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
379        }
380
381        // Return the final response as tool output
382        Ok(ToolOutput {
383            content: result.text,
384            success: true,
385            metadata: Some(serde_json::json!({
386                "skill_name": skill.name,
387                "tool_calls": result.tool_calls_count,
388                "usage": result.usage,
389            })),
390            images: Vec::new(),
391            error_kind: None,
392        })
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::budget::{BudgetDecision, BudgetGuard};
400    use crate::llm::{
401        ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
402    };
403    use crate::skills::SkillKind;
404    use crate::tools::ToolContext;
405    use anyhow::Result;
406    use async_trait::async_trait;
407    use std::path::PathBuf;
408    use std::sync::atomic::{AtomicUsize, Ordering};
409    use std::sync::Mutex;
410    use std::time::Duration;
411    use tokio::sync::{mpsc, Notify};
412    use tokio_util::sync::CancellationToken;
413
414    struct MockLlmClient {
415        responses: Mutex<Vec<LlmResponse>>,
416    }
417
418    impl MockLlmClient {
419        fn new(responses: Vec<LlmResponse>) -> Self {
420            Self {
421                responses: Mutex::new(responses),
422            }
423        }
424
425        fn text_response(text: &str) -> LlmResponse {
426            LlmResponse {
427                message: Message {
428                    role: "assistant".to_string(),
429                    content: vec![ContentBlock::Text {
430                        text: text.to_string(),
431                    }],
432                    reasoning_content: None,
433                },
434                usage: TokenUsage {
435                    prompt_tokens: 10,
436                    completion_tokens: 5,
437                    total_tokens: 15,
438                    cache_read_tokens: None,
439                    cache_write_tokens: None,
440                },
441                stop_reason: Some("end_turn".to_string()),
442                token_logprobs: Vec::new(),
443                meta: None,
444            }
445        }
446
447        fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
448            LlmResponse {
449                message: Message {
450                    role: "assistant".to_string(),
451                    content: vec![ContentBlock::ToolUse {
452                        id: id.to_string(),
453                        name: name.to_string(),
454                        input,
455                    }],
456                    reasoning_content: None,
457                },
458                usage: TokenUsage {
459                    prompt_tokens: 10,
460                    completion_tokens: 5,
461                    total_tokens: 15,
462                    cache_read_tokens: None,
463                    cache_write_tokens: None,
464                },
465                stop_reason: Some("tool_use".to_string()),
466                token_logprobs: Vec::new(),
467                meta: None,
468            }
469        }
470    }
471
472    #[async_trait]
473    impl LlmClient for MockLlmClient {
474        async fn complete(
475            &self,
476            _messages: &[Message],
477            _system: Option<&str>,
478            _tools: &[ToolDefinition],
479        ) -> Result<LlmResponse> {
480            let mut responses = self.responses.lock().unwrap();
481            if responses.is_empty() {
482                anyhow::bail!("No more mock responses available");
483            }
484            Ok(responses.remove(0))
485        }
486
487        async fn complete_streaming(
488            &self,
489            _messages: &[Message],
490            _system: Option<&str>,
491            _tools: &[ToolDefinition],
492            _cancel_token: tokio_util::sync::CancellationToken,
493        ) -> Result<mpsc::Receiver<StreamEvent>> {
494            anyhow::bail!("streaming not used in SkillTool tests")
495        }
496    }
497
498    #[derive(Default)]
499    struct SkillBudgetGuard {
500        checks: AtomicUsize,
501        records: AtomicUsize,
502        sessions: Mutex<Vec<String>>,
503    }
504
505    #[async_trait]
506    impl BudgetGuard for SkillBudgetGuard {
507        async fn check_before_llm(
508            &self,
509            session_id: &str,
510            _estimated_prompt_tokens: usize,
511        ) -> BudgetDecision {
512            self.checks.fetch_add(1, Ordering::SeqCst);
513            self.sessions.lock().unwrap().push(session_id.to_string());
514            BudgetDecision::Allow
515        }
516
517        async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
518            self.records.fetch_add(1, Ordering::SeqCst);
519        }
520    }
521
522    struct BlockingSkillClient {
523        started: Arc<Notify>,
524        calls: Arc<AtomicUsize>,
525    }
526
527    struct SkillSideEffectTool {
528        calls: Arc<AtomicUsize>,
529    }
530
531    #[async_trait]
532    impl Tool for SkillSideEffectTool {
533        fn name(&self) -> &str {
534            "side_effect"
535        }
536
537        fn description(&self) -> &str {
538            "Records a test-only side effect"
539        }
540
541        fn parameters(&self) -> serde_json::Value {
542            serde_json::json!({"type": "object", "additionalProperties": false})
543        }
544
545        async fn execute(
546            &self,
547            _args: &serde_json::Value,
548            _ctx: &ToolContext,
549        ) -> Result<ToolOutput> {
550            self.calls.fetch_add(1, Ordering::SeqCst);
551            Ok(ToolOutput::success("unexpected-side-effect"))
552        }
553    }
554
555    #[async_trait]
556    impl LlmClient for BlockingSkillClient {
557        async fn complete(
558            &self,
559            _messages: &[Message],
560            _system: Option<&str>,
561            _tools: &[ToolDefinition],
562        ) -> Result<LlmResponse> {
563            self.calls.fetch_add(1, Ordering::SeqCst);
564            self.started.notify_one();
565            std::future::pending::<Result<LlmResponse>>().await
566        }
567
568        async fn complete_streaming(
569            &self,
570            _messages: &[Message],
571            _system: Option<&str>,
572            _tools: &[ToolDefinition],
573            _cancel_token: CancellationToken,
574        ) -> Result<mpsc::Receiver<StreamEvent>> {
575            anyhow::bail!("streaming not used in SkillTool cancellation tests")
576        }
577    }
578
579    fn test_skill_registry() -> Arc<SkillRegistry> {
580        let registry = Arc::new(SkillRegistry::new());
581        registry.register_unchecked(Arc::new(Skill {
582            name: "test-skill".to_string(),
583            description: "Run a focused skill".to_string(),
584            allowed_tools: None,
585            disable_model_invocation: false,
586            kind: SkillKind::Instruction,
587            content: "Reply with the skill result.".to_string(),
588            tags: vec!["focus".to_string()],
589            version: None,
590        }));
591        registry
592    }
593
594    #[test]
595    fn test_skill_permission_policy() {
596        let skill = Skill {
597            name: "test-skill".to_string(),
598            description: "Test".to_string(),
599            allowed_tools: Some("read(*), grep(*)".to_string()),
600            disable_model_invocation: false,
601            kind: SkillKind::Instruction,
602            content: String::new(),
603            tags: Vec::new(),
604            version: None,
605        };
606
607        let policy = SkillTool::create_skill_permission_policy(&skill);
608
609        // Should allow tools in allowed-tools
610        assert_eq!(
611            policy.check("read", &serde_json::json!({})),
612            PermissionDecision::Allow
613        );
614        assert_eq!(
615            policy.check(
616                "search",
617                &serde_json::json!({"mode": "grep", "query": "TODO"}),
618            ),
619            PermissionDecision::Allow
620        );
621
622        // Should deny tools not in allowed-tools
623        assert_eq!(
624            policy.check("write", &serde_json::json!({})),
625            PermissionDecision::Deny
626        );
627    }
628
629    #[test]
630    fn test_skill_permission_policy_denies_when_unspecified() {
631        let skill = Skill {
632            name: "test-skill".to_string(),
633            description: "Test".to_string(),
634            allowed_tools: None,
635            disable_model_invocation: false,
636            kind: SkillKind::Instruction,
637            content: String::new(),
638            tags: Vec::new(),
639            version: None,
640        };
641
642        let policy = SkillTool::create_skill_permission_policy(&skill);
643
644        assert_eq!(
645            policy.check("bash", &serde_json::json!({"command": "python --version"})),
646            PermissionDecision::Deny
647        );
648        assert_eq!(
649            policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
650            PermissionDecision::Deny
651        );
652    }
653
654    #[test]
655    fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
656        let skill = Skill {
657            name: "test-skill".to_string(),
658            description: "Test".to_string(),
659            allowed_tools: Some("Read Write Edit Bash".to_string()),
660            disable_model_invocation: false,
661            kind: SkillKind::Instruction,
662            content: String::new(),
663            tags: Vec::new(),
664            version: None,
665        };
666
667        let policy = SkillTool::create_skill_permission_policy(&skill);
668
669        assert_eq!(
670            policy.check("bash", &serde_json::json!({"command": "python --version"})),
671            PermissionDecision::Allow
672        );
673        assert_eq!(
674            policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
675            PermissionDecision::Deny
676        );
677    }
678
679    #[test]
680    fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
681        let skill = Skill {
682            name: "test-skill".to_string(),
683            description: "Test".to_string(),
684            allowed_tools: Some("*".to_string()),
685            disable_model_invocation: false,
686            kind: SkillKind::Instruction,
687            content: String::new(),
688            tags: Vec::new(),
689            version: None,
690        };
691
692        let policy = SkillTool::create_skill_permission_policy(&skill);
693
694        assert_eq!(
695            policy.check("bash", &serde_json::json!({"command": "python --version"})),
696            PermissionDecision::Allow
697        );
698        assert_eq!(
699            policy.check("parallel_task", &serde_json::json!({"tasks": []})),
700            PermissionDecision::Allow
701        );
702    }
703
704    #[test]
705    fn test_skill_args_accepts_documented_shape() {
706        let args =
707            SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
708        assert_eq!(args.skill_name, "code-review");
709        assert_eq!(args.prompt, None);
710    }
711
712    #[test]
713    fn test_skill_args_accepts_common_aliases_and_wrappers() {
714        let camel =
715            SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
716        assert_eq!(camel.skill_name, "code-review");
717
718        let name = SkillArgs::from_tool_args(&serde_json::json!({
719            "name": "code-review",
720            "query": "review this patch"
721        }))
722        .unwrap();
723        assert_eq!(name.skill_name, "code-review");
724        assert_eq!(name.prompt.as_deref(), Some("review this patch"));
725
726        let nested = SkillArgs::from_tool_args(&serde_json::json!({
727            "input": {
728                "skill_name": "code-review",
729                "prompt": "review this patch"
730            }
731        }))
732        .unwrap();
733        assert_eq!(nested.skill_name, "code-review");
734        assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
735
736        let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
737        assert_eq!(direct.skill_name, "code-review");
738    }
739
740    #[test]
741    fn test_skill_args_missing_skill_name_errors() {
742        let err =
743            SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
744        assert!(err.to_string().contains("missing field 'skill_name'"));
745    }
746
747    #[test]
748    fn test_search_skills_args_accepts_string_and_object() {
749        let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
750        assert_eq!(direct.query, "review code");
751        assert_eq!(direct.limit, None);
752
753        let object =
754            SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
755                .unwrap();
756        assert_eq!(object.query, "review");
757        assert_eq!(object.limit, Some(2));
758    }
759
760    #[tokio::test]
761    async fn test_search_skills_tool_returns_matching_skills() {
762        let registry = Arc::new(SkillRegistry::new());
763        registry.register_unchecked(Arc::new(Skill {
764            name: "code-review".to_string(),
765            description: "Review code changes".to_string(),
766            allowed_tools: Some("read(*), grep(*)".to_string()),
767            disable_model_invocation: false,
768            kind: SkillKind::Instruction,
769            content: "Review instructions".to_string(),
770            tags: vec!["review".to_string()],
771            version: None,
772        }));
773
774        let tool = SearchSkillsTool::new(registry);
775        let result = tool
776            .execute(
777                &serde_json::json!({"query": "review"}),
778                &ToolContext::new(PathBuf::from("/tmp")),
779            )
780            .await
781            .unwrap();
782
783        assert!(result.success);
784        assert!(result.content.contains("code-review"));
785        assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
786    }
787
788    #[tokio::test]
789    async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
790        let registry = Arc::new(SkillRegistry::new());
791        for index in 0..25 {
792            registry.register_unchecked(Arc::new(Skill {
793                name: format!("review-{index:02}"),
794                description: "Review code changes".to_string(),
795                allowed_tools: Some("read(*)".to_string()),
796                disable_model_invocation: false,
797                kind: SkillKind::Instruction,
798                content: "Review instructions".to_string(),
799                tags: vec!["review".to_string()],
800                version: None,
801            }));
802        }
803        registry.register_unchecked(Arc::new(Skill {
804            name: "review-persona".to_string(),
805            description: "Review persona".to_string(),
806            allowed_tools: None,
807            disable_model_invocation: false,
808            kind: SkillKind::Persona,
809            content: "Persona instructions".to_string(),
810            tags: vec!["review".to_string()],
811            version: None,
812        }));
813
814        let tool = SearchSkillsTool::new(registry);
815        let result = tool
816            .execute(
817                &serde_json::json!({"query": "review", "limit": 100}),
818                &ToolContext::new(PathBuf::from("/tmp")),
819            )
820            .await
821            .unwrap();
822
823        let metadata = result.metadata.unwrap();
824        let skills = metadata["skills"].as_array().unwrap();
825        assert_eq!(skills.len(), 20);
826        assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
827    }
828
829    #[test]
830    fn test_skill_tool_schema_enforces_canonical_shape() {
831        let registry = Arc::new(SkillRegistry::new());
832        let llm = Arc::new(MockLlmClient::new(vec![]));
833        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
834        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
835
836        let params = tool.parameters();
837        assert_eq!(params["type"], "object");
838        assert_eq!(params["additionalProperties"], serde_json::json!(false));
839        assert_eq!(params["required"], serde_json::json!(["skill_name"]));
840
841        let examples = params["examples"].as_array().unwrap();
842        assert_eq!(examples[0]["skill_name"], "code-review");
843        assert!(examples[0].get("name").is_none());
844        assert!(examples[0].get("skillName").is_none());
845    }
846
847    #[tokio::test]
848    async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
849        use crate::prompts::PlanningMode;
850
851        let registry = Arc::new(SkillRegistry::new());
852        registry.register_unchecked(Arc::new(Skill {
853            name: "test-skill".to_string(),
854            description: "Run a focused skill".to_string(),
855            allowed_tools: None,
856            disable_model_invocation: false,
857            kind: SkillKind::Instruction,
858            content: "Reply with the skill result.".to_string(),
859            tags: vec!["focus".to_string()],
860            version: None,
861        }));
862
863        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
864            "skill completed",
865        )]));
866        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
867        // Disable planning mode since the mock only has one response
868        let config = AgentConfig {
869            planning_mode: PlanningMode::Disabled,
870            continuation_enabled: false,
871            ..Default::default()
872        };
873        let tool = SkillTool::new(registry, llm, executor, config);
874
875        let result = tool
876            .execute(
877                &serde_json::json!({
878                    "skill_name": "test-skill",
879                    "prompt": "verify the skill result"
880                }),
881                &ToolContext::new(PathBuf::from("/tmp")),
882            )
883            .await
884            .unwrap();
885
886        assert!(result.success);
887        assert_eq!(result.content, "skill completed");
888        let metadata = result.metadata.unwrap();
889        assert_eq!(metadata["skill_name"], "test-skill");
890        assert_eq!(metadata["tool_calls"], 0);
891    }
892
893    #[tokio::test]
894    async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
895        use crate::prompts::PlanningMode;
896
897        let workspace = tempfile::tempdir().unwrap();
898        let registry = Arc::new(SkillRegistry::new());
899        registry.register_unchecked(Arc::new(Skill {
900            name: "bash-skill".to_string(),
901            description: "Attempt a shell call".to_string(),
902            allowed_tools: Some("bash(*)".to_string()),
903            disable_model_invocation: false,
904            kind: SkillKind::Instruction,
905            content: "Use Bash once.".to_string(),
906            tags: Vec::new(),
907            version: None,
908        }));
909        let llm = Arc::new(MockLlmClient::new(vec![
910            MockLlmClient::tool_call_response(
911                "bash-call",
912                "bash",
913                serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
914            ),
915            MockLlmClient::text_response("The host boundary rejected the call."),
916        ]));
917        let parent_policy = PermissionPolicy::new().deny("bash(*)");
918        let config = AgentConfig {
919            planning_mode: PlanningMode::Disabled,
920            continuation_enabled: false,
921            permission_checker: Some(Arc::new(parent_policy.clone())),
922            permission_policy: Some(parent_policy),
923            ..Default::default()
924        };
925        let tool = SkillTool::new(
926            registry,
927            llm,
928            Arc::new(ToolExecutor::new(
929                workspace.path().to_string_lossy().into_owned(),
930            )),
931            config,
932        );
933
934        let result = tool
935            .execute(
936                &serde_json::json!({"skill_name": "bash-skill"}),
937                &ToolContext::new(workspace.path().to_path_buf()),
938            )
939            .await
940            .unwrap();
941
942        assert!(result.success, "{}", result.content);
943        assert!(
944            !workspace.path().join("skill-boundary-leak").exists(),
945            "a skill-local allow-list must not bypass the parent host boundary"
946        );
947    }
948
949    #[tokio::test]
950    async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
951        use crate::prompts::PlanningMode;
952
953        struct RecordingBoundary {
954            checked: Arc<Mutex<Vec<String>>>,
955        }
956
957        impl crate::permissions::PermissionChecker for RecordingBoundary {
958            fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
959                self.checked.lock().unwrap().push(tool_name.to_string());
960                if tool_name == "side_effect" {
961                    PermissionDecision::Deny
962                } else {
963                    PermissionDecision::Allow
964                }
965            }
966        }
967
968        let workspace = tempfile::tempdir().unwrap();
969        let calls = Arc::new(AtomicUsize::new(0));
970        let checked = Arc::new(Mutex::new(Vec::new()));
971        let registry = Arc::new(SkillRegistry::new());
972        registry.register_unchecked(Arc::new(Skill {
973            name: "orchestrator-skill".to_string(),
974            description: "Exercise governed orchestrators".to_string(),
975            allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
976            disable_model_invocation: false,
977            kind: SkillKind::Instruction,
978            content: "Run the requested orchestration.".to_string(),
979            tags: Vec::new(),
980            version: None,
981        }));
982        let llm = Arc::new(MockLlmClient::new(vec![
983            MockLlmClient::tool_call_response(
984                "model-batch",
985                "batch",
986                serde_json::json!({
987                    "invocations": [{
988                        "tool": "program",
989                        "args": {
990                            "type": "script",
991                            "language": "javascript",
992                            "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
993                            "allowed_tools": ["side_effect"]
994                        }
995                    }]
996                }),
997            ),
998            MockLlmClient::text_response("The boundary held."),
999        ]));
1000        let executor = Arc::new(ToolExecutor::new(
1001            workspace.path().to_string_lossy().into_owned(),
1002        ));
1003        executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1004            calls: Arc::clone(&calls),
1005        }));
1006        let tool = SkillTool::new(
1007            registry,
1008            llm,
1009            executor,
1010            AgentConfig {
1011                planning_mode: PlanningMode::Disabled,
1012                continuation_enabled: false,
1013                permission_checker: Some(Arc::new(RecordingBoundary {
1014                    checked: Arc::clone(&checked),
1015                })),
1016                ..Default::default()
1017            },
1018        );
1019        let context = ToolContext::new(workspace.path().to_path_buf())
1020            .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1021
1022        let result = tool
1023            .execute(
1024                &serde_json::json!({"skill_name": "orchestrator-skill"}),
1025                &context,
1026            )
1027            .await
1028            .unwrap();
1029
1030        assert!(result.success, "{}", result.content);
1031        assert_eq!(result.content, "The boundary held.");
1032        assert_eq!(calls.load(Ordering::SeqCst), 0);
1033        let checked = checked.lock().unwrap();
1034        for expected in ["batch", "program", "side_effect"] {
1035            assert!(
1036                checked.iter().any(|tool| tool == expected),
1037                "{expected} must cross the parent permission boundary: {checked:?}"
1038            );
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1044        use crate::prompts::PlanningMode;
1045
1046        let guard = Arc::new(SkillBudgetGuard::default());
1047        let config = AgentConfig {
1048            planning_mode: PlanningMode::Disabled,
1049            continuation_enabled: false,
1050            budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1051            ..Default::default()
1052        };
1053        let tool = SkillTool::new(
1054            test_skill_registry(),
1055            Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1056                "skill completed",
1057            )])),
1058            Arc::new(ToolExecutor::new("/tmp".to_string())),
1059            config,
1060        );
1061        let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
1062
1063        let result = tool
1064            .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1065            .await
1066            .unwrap();
1067
1068        assert!(result.success);
1069        assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1070        assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1071        assert_eq!(
1072            guard.sessions.lock().unwrap().as_slice(),
1073            &["parent-session".to_string()]
1074        );
1075    }
1076
1077    #[tokio::test]
1078    async fn skill_child_llm_call_stops_on_parent_cancellation() {
1079        use crate::prompts::PlanningMode;
1080
1081        let started = Arc::new(Notify::new());
1082        let calls = Arc::new(AtomicUsize::new(0));
1083        let tool = SkillTool::new(
1084            test_skill_registry(),
1085            Arc::new(BlockingSkillClient {
1086                started: Arc::clone(&started),
1087                calls: Arc::clone(&calls),
1088            }),
1089            Arc::new(ToolExecutor::new("/tmp".to_string())),
1090            AgentConfig {
1091                planning_mode: PlanningMode::Disabled,
1092                continuation_enabled: false,
1093                ..Default::default()
1094            },
1095        );
1096        let cancellation = CancellationToken::new();
1097        let ctx = ToolContext::new(PathBuf::from("/tmp"))
1098            .with_session_id("parent-session")
1099            .with_cancellation(cancellation.clone());
1100        let started_wait = started.notified();
1101        let run = tokio::spawn(async move {
1102            tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1103                .await
1104        });
1105
1106        tokio::time::timeout(Duration::from_secs(1), started_wait)
1107            .await
1108            .expect("skill provider call should start");
1109        cancellation.cancel();
1110        let error = tokio::time::timeout(Duration::from_secs(1), run)
1111            .await
1112            .expect("parent cancellation must stop the skill child")
1113            .expect("skill join should succeed")
1114            .expect_err("cancelled skill must not return success");
1115
1116        assert!(error.to_string().contains("cancelled"));
1117        assert_eq!(calls.load(Ordering::SeqCst), 1);
1118    }
1119
1120    #[tokio::test]
1121    async fn test_skill_tool_execute_errors_for_unknown_skill() {
1122        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1123            "unused",
1124        )]));
1125        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
1126        let tool = SkillTool::new(
1127            Arc::new(SkillRegistry::new()),
1128            llm,
1129            executor,
1130            AgentConfig::default(),
1131        );
1132
1133        let err = tool
1134            .execute(
1135                &serde_json::json!({"skill_name": "missing-skill"}),
1136                &ToolContext::new(PathBuf::from("/tmp")),
1137            )
1138            .await
1139            .unwrap_err();
1140
1141        assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1142    }
1143}