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