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        let capability_subtask = ctx
351            .capability_context()
352            .map(|context| context.admit_subtask(format!("skill-{}", uuid::Uuid::new_v4()), false))
353            .transpose()?;
354        let cancellation = capability_subtask.as_ref().map_or_else(
355            || ctx.cancellation_token(),
356            crate::capability::AgentCapabilitySubtask::cancellation,
357        );
358
359        // Create the child Agent loop inside the Skill's spatial Subtask. Its
360        // provider/tool iterations will recursively create temporal Turns.
361        let mut agent_loop = AgentLoop::new(
362            self.llm_client.clone(),
363            self.tool_executor.clone(),
364            ctx.clone(),
365            skill_config,
366        );
367        if let Some(subtask) = capability_subtask.as_ref() {
368            agent_loop = agent_loop.with_capability_runtime(subtask.runtime());
369        }
370
371        // Execute the skill with the prompt
372        let prompt = args
373            .prompt
374            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
375
376        // The skill remains inside the owning Run's cancellation and budget
377        // scope. Its Subtask token is derived from the invoking Turn, so parent
378        // cancellation reaches every provider/tool call without allowing the
379        // child to cancel its parent.
380        let execution = agent_loop
381            .execute_with_session(
382                &[],
383                &prompt,
384                ctx.session_id.as_deref(),
385                None,
386                Some(&cancellation),
387            )
388            .await;
389        let cancelled = cancellation.is_cancelled();
390        let close = close_skill_capability_subtask(capability_subtask.as_ref()).await;
391        let result = match (execution, close) {
392            (Ok(result), Ok(())) => result,
393            (Ok(_), Err(close_error)) => return Err(close_error),
394            (Err(error), Ok(())) => return Err(error),
395            (Err(error), Err(close_error)) => {
396                tracing::warn!(
397                    error = %close_error,
398                    "Capability Skill Subtask close also failed after execution failure"
399                );
400                return Err(error);
401            }
402        };
403        if cancelled {
404            anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
405        }
406
407        // Return the final response as tool output
408        Ok(ToolOutput {
409            content: result.text,
410            success: true,
411            metadata: Some(serde_json::json!({
412                "skill_name": skill.name,
413                "tool_calls": result.tool_calls_count,
414                "usage": result.usage,
415            })),
416            images: Vec::new(),
417            error_kind: None,
418        })
419    }
420}
421
422async fn close_skill_capability_subtask(
423    subtask: Option<&crate::capability::AgentCapabilitySubtask>,
424) -> Result<()> {
425    let Some(subtask) = subtask else {
426        return Ok(());
427    };
428    let report = subtask.close().await?;
429    if !report.is_clean() {
430        anyhow::bail!(
431            "Capability Skill Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
432            report.tasks_failed,
433            report.tasks_timed_out,
434            report.child_scopes_failed,
435            report.child_scopes_timed_out,
436            report.effects_failed,
437            report.effects_timed_out,
438        );
439    }
440    Ok(())
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::budget::{BudgetDecision, BudgetGuard};
447    use crate::llm::{
448        ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
449    };
450    use crate::skills::SkillKind;
451    use crate::tools::ToolContext;
452    use anyhow::Result;
453    use async_trait::async_trait;
454    use std::path::PathBuf;
455    use std::sync::atomic::{AtomicUsize, Ordering};
456    use std::sync::Mutex;
457    use std::time::Duration;
458    use tokio::sync::{mpsc, Notify};
459    use tokio_util::sync::CancellationToken;
460
461    struct MockLlmClient {
462        responses: Mutex<Vec<LlmResponse>>,
463    }
464
465    impl MockLlmClient {
466        fn new(responses: Vec<LlmResponse>) -> Self {
467            Self {
468                responses: Mutex::new(responses),
469            }
470        }
471
472        fn text_response(text: &str) -> LlmResponse {
473            LlmResponse {
474                message: Message {
475                    role: "assistant".to_string(),
476                    content: vec![ContentBlock::Text {
477                        text: text.to_string(),
478                    }],
479                    reasoning_content: None,
480                },
481                usage: TokenUsage {
482                    prompt_tokens: 10,
483                    completion_tokens: 5,
484                    total_tokens: 15,
485                    cache_read_tokens: None,
486                    cache_write_tokens: None,
487                },
488                stop_reason: Some("end_turn".to_string()),
489                token_logprobs: Vec::new(),
490                meta: None,
491            }
492        }
493
494        fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
495            LlmResponse {
496                message: Message {
497                    role: "assistant".to_string(),
498                    content: vec![ContentBlock::ToolUse {
499                        id: id.to_string(),
500                        name: name.to_string(),
501                        input,
502                    }],
503                    reasoning_content: None,
504                },
505                usage: TokenUsage {
506                    prompt_tokens: 10,
507                    completion_tokens: 5,
508                    total_tokens: 15,
509                    cache_read_tokens: None,
510                    cache_write_tokens: None,
511                },
512                stop_reason: Some("tool_use".to_string()),
513                token_logprobs: Vec::new(),
514                meta: None,
515            }
516        }
517    }
518
519    #[async_trait]
520    impl LlmClient for MockLlmClient {
521        async fn complete(
522            &self,
523            _messages: &[Message],
524            _system: Option<&str>,
525            _tools: &[ToolDefinition],
526        ) -> Result<LlmResponse> {
527            let mut responses = self.responses.lock().unwrap();
528            if responses.is_empty() {
529                anyhow::bail!("No more mock responses available");
530            }
531            Ok(responses.remove(0))
532        }
533
534        async fn complete_streaming(
535            &self,
536            _messages: &[Message],
537            _system: Option<&str>,
538            _tools: &[ToolDefinition],
539            _cancel_token: tokio_util::sync::CancellationToken,
540        ) -> Result<mpsc::Receiver<StreamEvent>> {
541            anyhow::bail!("streaming not used in SkillTool tests")
542        }
543    }
544
545    #[derive(Default)]
546    struct SkillBudgetGuard {
547        checks: AtomicUsize,
548        records: AtomicUsize,
549        sessions: Mutex<Vec<String>>,
550    }
551
552    #[async_trait]
553    impl BudgetGuard for SkillBudgetGuard {
554        async fn check_before_llm(
555            &self,
556            session_id: &str,
557            _estimated_prompt_tokens: usize,
558        ) -> BudgetDecision {
559            self.checks.fetch_add(1, Ordering::SeqCst);
560            self.sessions.lock().unwrap().push(session_id.to_string());
561            BudgetDecision::Allow
562        }
563
564        async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
565            self.records.fetch_add(1, Ordering::SeqCst);
566        }
567    }
568
569    struct BlockingSkillClient {
570        started: Arc<Notify>,
571        calls: Arc<AtomicUsize>,
572    }
573
574    struct SkillSideEffectTool {
575        calls: Arc<AtomicUsize>,
576    }
577
578    struct SkillScopeProbeTool {
579        log: Arc<Mutex<Vec<String>>>,
580    }
581
582    struct SkillScopeProbeEffect {
583        scope_id: String,
584        log: Arc<Mutex<Vec<String>>>,
585    }
586
587    #[async_trait]
588    impl crate::capability::CapabilityEffect for SkillScopeProbeEffect {
589        fn name(&self) -> &str {
590            "test.skill.scope.effect"
591        }
592
593        async fn close(
594            self: Box<Self>,
595        ) -> std::result::Result<(), crate::capability::CapabilityEffectError> {
596            self.log
597                .lock()
598                .unwrap()
599                .push(format!("closed:{}", self.scope_id));
600            Ok(())
601        }
602    }
603
604    #[async_trait]
605    impl Tool for SkillScopeProbeTool {
606        fn name(&self) -> &str {
607            "skill_scope_probe"
608        }
609
610        fn description(&self) -> &str {
611            "Records the capability Turn owned by a nested Skill Agent"
612        }
613
614        fn parameters(&self) -> serde_json::Value {
615            serde_json::json!({"type": "object", "additionalProperties": false})
616        }
617
618        fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
619            crate::tools::ToolCapabilities::parallel_safe_read(1)
620        }
621
622        async fn execute(
623            &self,
624            _args: &serde_json::Value,
625            ctx: &ToolContext,
626        ) -> Result<ToolOutput> {
627            let scope_id = ctx
628                .capability_scope_id()
629                .ok_or_else(|| anyhow!("nested Skill Tool has no capability Turn"))?
630                .to_owned();
631            self.log
632                .lock()
633                .unwrap()
634                .push(format!("registered:{scope_id}"));
635            ctx.register_capability_effect(SkillScopeProbeEffect {
636                scope_id,
637                log: Arc::clone(&self.log),
638            })?;
639            Ok(ToolOutput::success("registered"))
640        }
641    }
642
643    #[async_trait]
644    impl Tool for SkillSideEffectTool {
645        fn name(&self) -> &str {
646            "side_effect"
647        }
648
649        fn description(&self) -> &str {
650            "Records a test-only side effect"
651        }
652
653        fn parameters(&self) -> serde_json::Value {
654            serde_json::json!({"type": "object", "additionalProperties": false})
655        }
656
657        async fn execute(
658            &self,
659            _args: &serde_json::Value,
660            _ctx: &ToolContext,
661        ) -> Result<ToolOutput> {
662            self.calls.fetch_add(1, Ordering::SeqCst);
663            Ok(ToolOutput::success("unexpected-side-effect"))
664        }
665    }
666
667    #[async_trait]
668    impl LlmClient for BlockingSkillClient {
669        async fn complete(
670            &self,
671            _messages: &[Message],
672            _system: Option<&str>,
673            _tools: &[ToolDefinition],
674        ) -> Result<LlmResponse> {
675            self.calls.fetch_add(1, Ordering::SeqCst);
676            self.started.notify_one();
677            std::future::pending::<Result<LlmResponse>>().await
678        }
679
680        async fn complete_streaming(
681            &self,
682            _messages: &[Message],
683            _system: Option<&str>,
684            _tools: &[ToolDefinition],
685            _cancel_token: CancellationToken,
686        ) -> Result<mpsc::Receiver<StreamEvent>> {
687            anyhow::bail!("streaming not used in SkillTool cancellation tests")
688        }
689    }
690
691    fn test_skill_registry() -> Arc<SkillRegistry> {
692        let registry = Arc::new(SkillRegistry::new());
693        registry.register_unchecked(Arc::new(Skill {
694            name: "test-skill".to_string(),
695            description: "Run a focused skill".to_string(),
696            allowed_tools: None,
697            disable_model_invocation: false,
698            kind: SkillKind::Instruction,
699            content: "Reply with the skill result.".to_string(),
700            tags: vec!["focus".to_string()],
701            version: None,
702        }));
703        registry
704    }
705
706    #[test]
707    fn test_skill_permission_policy() {
708        let skill = Skill {
709            name: "test-skill".to_string(),
710            description: "Test".to_string(),
711            allowed_tools: Some("read(*), grep(*)".to_string()),
712            disable_model_invocation: false,
713            kind: SkillKind::Instruction,
714            content: String::new(),
715            tags: Vec::new(),
716            version: None,
717        };
718
719        let policy = SkillTool::create_skill_permission_policy(&skill);
720
721        // Should allow tools in allowed-tools
722        assert_eq!(
723            policy.check("read", &serde_json::json!({})),
724            PermissionDecision::Allow
725        );
726        assert_eq!(
727            policy.check(
728                "search",
729                &serde_json::json!({"mode": "grep", "query": "TODO"}),
730            ),
731            PermissionDecision::Allow
732        );
733
734        // Should deny tools not in allowed-tools
735        assert_eq!(
736            policy.check("write", &serde_json::json!({})),
737            PermissionDecision::Deny
738        );
739    }
740
741    #[test]
742    fn test_skill_permission_policy_denies_when_unspecified() {
743        let skill = Skill {
744            name: "test-skill".to_string(),
745            description: "Test".to_string(),
746            allowed_tools: None,
747            disable_model_invocation: false,
748            kind: SkillKind::Instruction,
749            content: String::new(),
750            tags: Vec::new(),
751            version: None,
752        };
753
754        let policy = SkillTool::create_skill_permission_policy(&skill);
755
756        assert_eq!(
757            policy.check("bash", &serde_json::json!({"command": "python --version"})),
758            PermissionDecision::Deny
759        );
760        assert_eq!(
761            policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
762            PermissionDecision::Deny
763        );
764    }
765
766    #[test]
767    fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
768        let skill = Skill {
769            name: "test-skill".to_string(),
770            description: "Test".to_string(),
771            allowed_tools: Some("Read Write Edit Bash".to_string()),
772            disable_model_invocation: false,
773            kind: SkillKind::Instruction,
774            content: String::new(),
775            tags: Vec::new(),
776            version: None,
777        };
778
779        let policy = SkillTool::create_skill_permission_policy(&skill);
780
781        assert_eq!(
782            policy.check("bash", &serde_json::json!({"command": "python --version"})),
783            PermissionDecision::Allow
784        );
785        assert_eq!(
786            policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
787            PermissionDecision::Deny
788        );
789    }
790
791    #[test]
792    fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
793        let skill = Skill {
794            name: "test-skill".to_string(),
795            description: "Test".to_string(),
796            allowed_tools: Some("*".to_string()),
797            disable_model_invocation: false,
798            kind: SkillKind::Instruction,
799            content: String::new(),
800            tags: Vec::new(),
801            version: None,
802        };
803
804        let policy = SkillTool::create_skill_permission_policy(&skill);
805
806        assert_eq!(
807            policy.check("bash", &serde_json::json!({"command": "python --version"})),
808            PermissionDecision::Allow
809        );
810        assert_eq!(
811            policy.check("parallel_task", &serde_json::json!({"tasks": []})),
812            PermissionDecision::Allow
813        );
814    }
815
816    #[test]
817    fn test_skill_args_accepts_documented_shape() {
818        let args =
819            SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
820        assert_eq!(args.skill_name, "code-review");
821        assert_eq!(args.prompt, None);
822    }
823
824    #[test]
825    fn test_skill_args_accepts_common_aliases_and_wrappers() {
826        let camel =
827            SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
828        assert_eq!(camel.skill_name, "code-review");
829
830        let name = SkillArgs::from_tool_args(&serde_json::json!({
831            "name": "code-review",
832            "query": "review this patch"
833        }))
834        .unwrap();
835        assert_eq!(name.skill_name, "code-review");
836        assert_eq!(name.prompt.as_deref(), Some("review this patch"));
837
838        let nested = SkillArgs::from_tool_args(&serde_json::json!({
839            "input": {
840                "skill_name": "code-review",
841                "prompt": "review this patch"
842            }
843        }))
844        .unwrap();
845        assert_eq!(nested.skill_name, "code-review");
846        assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
847
848        let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
849        assert_eq!(direct.skill_name, "code-review");
850    }
851
852    #[test]
853    fn test_skill_args_missing_skill_name_errors() {
854        let err =
855            SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
856        assert!(err.to_string().contains("missing field 'skill_name'"));
857    }
858
859    #[test]
860    fn test_search_skills_args_accepts_string_and_object() {
861        let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
862        assert_eq!(direct.query, "review code");
863        assert_eq!(direct.limit, None);
864
865        let object =
866            SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
867                .unwrap();
868        assert_eq!(object.query, "review");
869        assert_eq!(object.limit, Some(2));
870    }
871
872    #[tokio::test]
873    async fn test_search_skills_tool_returns_matching_skills() {
874        let registry = Arc::new(SkillRegistry::new());
875        registry.register_unchecked(Arc::new(Skill {
876            name: "code-review".to_string(),
877            description: "Review code changes".to_string(),
878            allowed_tools: Some("read(*), grep(*)".to_string()),
879            disable_model_invocation: false,
880            kind: SkillKind::Instruction,
881            content: "Review instructions".to_string(),
882            tags: vec!["review".to_string()],
883            version: None,
884        }));
885
886        let tool = SearchSkillsTool::new(registry);
887        let result = tool
888            .execute(
889                &serde_json::json!({"query": "review"}),
890                &ToolContext::new(PathBuf::from("/tmp")),
891            )
892            .await
893            .unwrap();
894
895        assert!(result.success);
896        assert!(result.content.contains("code-review"));
897        assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
898    }
899
900    #[tokio::test]
901    async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
902        let registry = Arc::new(SkillRegistry::new());
903        for index in 0..25 {
904            registry.register_unchecked(Arc::new(Skill {
905                name: format!("review-{index:02}"),
906                description: "Review code changes".to_string(),
907                allowed_tools: Some("read(*)".to_string()),
908                disable_model_invocation: false,
909                kind: SkillKind::Instruction,
910                content: "Review instructions".to_string(),
911                tags: vec!["review".to_string()],
912                version: None,
913            }));
914        }
915        registry.register_unchecked(Arc::new(Skill {
916            name: "review-persona".to_string(),
917            description: "Review persona".to_string(),
918            allowed_tools: None,
919            disable_model_invocation: false,
920            kind: SkillKind::Persona,
921            content: "Persona instructions".to_string(),
922            tags: vec!["review".to_string()],
923            version: None,
924        }));
925
926        let tool = SearchSkillsTool::new(registry);
927        let result = tool
928            .execute(
929                &serde_json::json!({"query": "review", "limit": 100}),
930                &ToolContext::new(PathBuf::from("/tmp")),
931            )
932            .await
933            .unwrap();
934
935        let metadata = result.metadata.unwrap();
936        let skills = metadata["skills"].as_array().unwrap();
937        assert_eq!(skills.len(), 20);
938        assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
939    }
940
941    #[test]
942    fn test_skill_tool_schema_enforces_canonical_shape() {
943        let registry = Arc::new(SkillRegistry::new());
944        let llm = Arc::new(MockLlmClient::new(vec![]));
945        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
946        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
947
948        let params = tool.parameters();
949        assert_eq!(params["type"], "object");
950        assert_eq!(params["additionalProperties"], serde_json::json!(false));
951        assert_eq!(params["required"], serde_json::json!(["skill_name"]));
952
953        let examples = params["examples"].as_array().unwrap();
954        assert_eq!(examples[0]["skill_name"], "code-review");
955        assert!(examples[0].get("name").is_none());
956        assert!(examples[0].get("skillName").is_none());
957    }
958
959    #[tokio::test]
960    async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
961        use crate::prompts::PlanningMode;
962
963        let registry = Arc::new(SkillRegistry::new());
964        registry.register_unchecked(Arc::new(Skill {
965            name: "test-skill".to_string(),
966            description: "Run a focused skill".to_string(),
967            allowed_tools: None,
968            disable_model_invocation: false,
969            kind: SkillKind::Instruction,
970            content: "Reply with the skill result.".to_string(),
971            tags: vec!["focus".to_string()],
972            version: None,
973        }));
974
975        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
976            "skill completed",
977        )]));
978        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
979        // Disable planning mode since the mock only has one response
980        let config = AgentConfig {
981            planning_mode: PlanningMode::Disabled,
982            continuation_enabled: false,
983            ..Default::default()
984        };
985        let tool = SkillTool::new(registry, llm, executor, config);
986
987        let result = tool
988            .execute(
989                &serde_json::json!({
990                    "skill_name": "test-skill",
991                    "prompt": "verify the skill result"
992                }),
993                &ToolContext::new(PathBuf::from("/tmp")),
994            )
995            .await
996            .unwrap();
997
998        assert!(result.success);
999        assert_eq!(result.content, "skill completed");
1000        let metadata = result.metadata.unwrap();
1001        assert_eq!(metadata["skill_name"], "test-skill");
1002        assert_eq!(metadata["tool_calls"], 0);
1003    }
1004
1005    #[tokio::test]
1006    async fn skill_child_agent_owns_recursive_subtask_and_turn_scopes() {
1007        use crate::prompts::PlanningMode;
1008
1009        let workspace = tempfile::tempdir().unwrap();
1010        let registry = Arc::new(SkillRegistry::new());
1011        registry.register_unchecked(Arc::new(Skill {
1012            name: "scoped-skill".to_string(),
1013            description: "Exercise nested scope ownership".to_string(),
1014            allowed_tools: Some("skill_scope_probe".to_string()),
1015            disable_model_invocation: false,
1016            kind: SkillKind::Instruction,
1017            content: "Call skill_scope_probe once, then finish.".to_string(),
1018            tags: Vec::new(),
1019            version: None,
1020        }));
1021        let log = Arc::new(Mutex::new(Vec::new()));
1022        let executor = Arc::new(ToolExecutor::new(
1023            workspace.path().to_string_lossy().into_owned(),
1024        ));
1025        executor.register_dynamic_tool(Arc::new(SkillScopeProbeTool {
1026            log: Arc::clone(&log),
1027        }));
1028        let tool = SkillTool::new(
1029            registry,
1030            Arc::new(MockLlmClient::new(vec![
1031                MockLlmClient::tool_call_response(
1032                    "skill-probe",
1033                    "skill_scope_probe",
1034                    serde_json::json!({}),
1035                ),
1036                MockLlmClient::text_response("skill complete"),
1037            ])),
1038            executor,
1039            AgentConfig {
1040                planning_mode: PlanningMode::Disabled,
1041                continuation_enabled: false,
1042                ..Default::default()
1043            },
1044        );
1045        let set = crate::capability::CapabilitySet::empty().unwrap();
1046        let ceiling = crate::capability::CapabilityCeiling::all(
1047            &set,
1048            crate::capability::WorkspaceCapabilityCeiling::all(),
1049            crate::capability::GovernanceCapabilityCeiling::none_required(),
1050            crate::capability::CapabilityExecutionCeiling::new(
1051                4,
1052                2,
1053                Some(1_000),
1054                Some(1_000),
1055                Some(5_000),
1056            )
1057            .unwrap(),
1058        )
1059        .unwrap();
1060        let session =
1061            crate::capability::CapabilityScope::<crate::capability::Session>::new_session(
1062                "session-1",
1063                set,
1064                ceiling.clone(),
1065            )
1066            .unwrap();
1067        let run = session.admit_run("run-1", ceiling).unwrap();
1068        let runtime = crate::capability::AgentCapabilityRuntime::from_run(&run);
1069        let parent_turn = runtime.begin_turn(1).unwrap();
1070        let capability_context = parent_turn.tool_context();
1071        let parent_scope_id = capability_context.foreground_scope_id().to_owned();
1072        let context = ToolContext::new(workspace.path().to_path_buf())
1073            .with_cancellation(parent_turn.cancellation())
1074            .with_capability_context(capability_context);
1075
1076        let result = tool
1077            .execute(&serde_json::json!({"skill_name": "scoped-skill"}), &context)
1078            .await
1079            .unwrap();
1080
1081        assert!(result.success, "{}", result.content);
1082        let entries = log.lock().unwrap().clone();
1083        assert_eq!(entries.len(), 2, "{entries:?}");
1084        let registered = entries[0].strip_prefix("registered:").unwrap();
1085        let closed = entries[1].strip_prefix("closed:").unwrap();
1086        assert_eq!(registered, closed);
1087        assert!(
1088            registered.starts_with(&format!("{parent_scope_id}/subtask/skill-")),
1089            "{registered}"
1090        );
1091        assert!(registered.contains("/turn/turn-1-1"), "{registered}");
1092
1093        parent_turn.close().await.unwrap();
1094        run.close().await.unwrap();
1095        session.close().await.unwrap();
1096    }
1097
1098    #[tokio::test]
1099    async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
1100        use crate::prompts::PlanningMode;
1101
1102        let workspace = tempfile::tempdir().unwrap();
1103        let registry = Arc::new(SkillRegistry::new());
1104        registry.register_unchecked(Arc::new(Skill {
1105            name: "bash-skill".to_string(),
1106            description: "Attempt a shell call".to_string(),
1107            allowed_tools: Some("bash(*)".to_string()),
1108            disable_model_invocation: false,
1109            kind: SkillKind::Instruction,
1110            content: "Use Bash once.".to_string(),
1111            tags: Vec::new(),
1112            version: None,
1113        }));
1114        let llm = Arc::new(MockLlmClient::new(vec![
1115            MockLlmClient::tool_call_response(
1116                "bash-call",
1117                "bash",
1118                serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
1119            ),
1120            MockLlmClient::text_response("The host boundary rejected the call."),
1121        ]));
1122        let parent_policy = PermissionPolicy::new().deny("bash(*)");
1123        let config = AgentConfig {
1124            planning_mode: PlanningMode::Disabled,
1125            continuation_enabled: false,
1126            permission_checker: Some(Arc::new(parent_policy.clone())),
1127            permission_policy: Some(parent_policy),
1128            ..Default::default()
1129        };
1130        let tool = SkillTool::new(
1131            registry,
1132            llm,
1133            Arc::new(ToolExecutor::new(
1134                workspace.path().to_string_lossy().into_owned(),
1135            )),
1136            config,
1137        );
1138
1139        let result = tool
1140            .execute(
1141                &serde_json::json!({"skill_name": "bash-skill"}),
1142                &ToolContext::new(workspace.path().to_path_buf()),
1143            )
1144            .await
1145            .unwrap();
1146
1147        assert!(result.success, "{}", result.content);
1148        assert!(
1149            !workspace.path().join("skill-boundary-leak").exists(),
1150            "a skill-local allow-list must not bypass the parent host boundary"
1151        );
1152    }
1153
1154    #[tokio::test]
1155    async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
1156        use crate::prompts::PlanningMode;
1157
1158        struct RecordingBoundary {
1159            checked: Arc<Mutex<Vec<String>>>,
1160        }
1161
1162        impl crate::permissions::PermissionChecker for RecordingBoundary {
1163            fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
1164                self.checked.lock().unwrap().push(tool_name.to_string());
1165                if tool_name == "side_effect" {
1166                    PermissionDecision::Deny
1167                } else {
1168                    PermissionDecision::Allow
1169                }
1170            }
1171        }
1172
1173        let workspace = tempfile::tempdir().unwrap();
1174        let calls = Arc::new(AtomicUsize::new(0));
1175        let checked = Arc::new(Mutex::new(Vec::new()));
1176        let registry = Arc::new(SkillRegistry::new());
1177        registry.register_unchecked(Arc::new(Skill {
1178            name: "orchestrator-skill".to_string(),
1179            description: "Exercise governed orchestrators".to_string(),
1180            allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
1181            disable_model_invocation: false,
1182            kind: SkillKind::Instruction,
1183            content: "Run the requested orchestration.".to_string(),
1184            tags: Vec::new(),
1185            version: None,
1186        }));
1187        let llm = Arc::new(MockLlmClient::new(vec![
1188            MockLlmClient::tool_call_response(
1189                "model-batch",
1190                "batch",
1191                serde_json::json!({
1192                    "invocations": [{
1193                        "tool": "program",
1194                        "args": {
1195                            "type": "script",
1196                            "language": "javascript",
1197                            "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
1198                            "allowed_tools": ["side_effect"]
1199                        }
1200                    }]
1201                }),
1202            ),
1203            MockLlmClient::text_response("The boundary held."),
1204        ]));
1205        let executor = Arc::new(ToolExecutor::new(
1206            workspace.path().to_string_lossy().into_owned(),
1207        ));
1208        executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1209            calls: Arc::clone(&calls),
1210        }));
1211        let tool = SkillTool::new(
1212            registry,
1213            llm,
1214            executor,
1215            AgentConfig {
1216                planning_mode: PlanningMode::Disabled,
1217                continuation_enabled: false,
1218                permission_checker: Some(Arc::new(RecordingBoundary {
1219                    checked: Arc::clone(&checked),
1220                })),
1221                ..Default::default()
1222            },
1223        );
1224        let context = ToolContext::new(workspace.path().to_path_buf())
1225            .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1226
1227        let result = tool
1228            .execute(
1229                &serde_json::json!({"skill_name": "orchestrator-skill"}),
1230                &context,
1231            )
1232            .await
1233            .unwrap();
1234
1235        assert!(result.success, "{}", result.content);
1236        assert_eq!(result.content, "The boundary held.");
1237        assert_eq!(calls.load(Ordering::SeqCst), 0);
1238        let checked = checked.lock().unwrap();
1239        for expected in ["batch", "program", "side_effect"] {
1240            assert!(
1241                checked.iter().any(|tool| tool == expected),
1242                "{expected} must cross the parent permission boundary: {checked:?}"
1243            );
1244        }
1245    }
1246
1247    #[tokio::test]
1248    async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1249        use crate::prompts::PlanningMode;
1250
1251        let guard = Arc::new(SkillBudgetGuard::default());
1252        let config = AgentConfig {
1253            planning_mode: PlanningMode::Disabled,
1254            continuation_enabled: false,
1255            budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1256            ..Default::default()
1257        };
1258        let tool = SkillTool::new(
1259            test_skill_registry(),
1260            Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1261                "skill completed",
1262            )])),
1263            Arc::new(ToolExecutor::new("/tmp".to_string())),
1264            config,
1265        );
1266        let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
1267
1268        let result = tool
1269            .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1270            .await
1271            .unwrap();
1272
1273        assert!(result.success);
1274        assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1275        assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1276        assert_eq!(
1277            guard.sessions.lock().unwrap().as_slice(),
1278            &["parent-session".to_string()]
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn skill_child_llm_call_stops_on_parent_cancellation() {
1284        use crate::prompts::PlanningMode;
1285
1286        let started = Arc::new(Notify::new());
1287        let calls = Arc::new(AtomicUsize::new(0));
1288        let tool = SkillTool::new(
1289            test_skill_registry(),
1290            Arc::new(BlockingSkillClient {
1291                started: Arc::clone(&started),
1292                calls: Arc::clone(&calls),
1293            }),
1294            Arc::new(ToolExecutor::new("/tmp".to_string())),
1295            AgentConfig {
1296                planning_mode: PlanningMode::Disabled,
1297                continuation_enabled: false,
1298                ..Default::default()
1299            },
1300        );
1301        let cancellation = CancellationToken::new();
1302        let ctx = ToolContext::new(PathBuf::from("/tmp"))
1303            .with_session_id("parent-session")
1304            .with_cancellation(cancellation.clone());
1305        let started_wait = started.notified();
1306        let run = tokio::spawn(async move {
1307            tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1308                .await
1309        });
1310
1311        tokio::time::timeout(Duration::from_secs(1), started_wait)
1312            .await
1313            .expect("skill provider call should start");
1314        cancellation.cancel();
1315        let error = tokio::time::timeout(Duration::from_secs(1), run)
1316            .await
1317            .expect("parent cancellation must stop the skill child")
1318            .expect("skill join should succeed")
1319            .expect_err("cancelled skill must not return success");
1320
1321        assert!(error.to_string().contains("cancelled"));
1322        assert_eq!(calls.load(Ordering::SeqCst), 1);
1323    }
1324
1325    #[tokio::test]
1326    async fn test_skill_tool_execute_errors_for_unknown_skill() {
1327        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1328            "unused",
1329        )]));
1330        let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
1331        let tool = SkillTool::new(
1332            Arc::new(SkillRegistry::new()),
1333            llm,
1334            executor,
1335            AgentConfig::default(),
1336        );
1337
1338        let err = tool
1339            .execute(
1340                &serde_json::json!({"skill_name": "missing-skill"}),
1341                &ToolContext::new(PathBuf::from("/tmp")),
1342            )
1343            .await
1344            .unwrap_err();
1345
1346        assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1347    }
1348}