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