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        if skill.disable_model_invocation {
350            anyhow::bail!(
351                "Skill '{}' disables model invocation and cannot be invoked via the Skill tool",
352                skill.name
353            );
354        }
355
356        // Create temporary permission policy with skill's allowed-tools
357        let skill_permission_policy = Self::create_skill_permission_policy(&skill);
358
359        // Create a modified config with the skill's permissions
360        let mut skill_config = self.base_config.clone();
361        if ctx.has_run_governance() {
362            skill_config.permission_checker = ctx.run_permission_checker();
363            skill_config.confirmation_manager = ctx.run_confirmation_manager();
364        }
365
366        // A skill narrows the tools it may use; it must not replace the host
367        // boundary. Compose both checkers so TUI mode decisions, sandbox
368        // availability, and explicit escalation denials remain authoritative
369        // inside the skill child run.
370        let parent_checker = skill_config.permission_checker.take();
371        let parent_policy = skill_config.permission_policy.clone();
372        skill_config.permission_checker = Some(crate::child_run::compose_permission_checker(
373            Arc::new(skill_permission_policy.clone()),
374            Some(skill_permission_policy.clone()),
375            parent_checker,
376            parent_policy,
377        ));
378        skill_config.permission_policy = Some(skill_permission_policy);
379        skill_config.enforce_active_skill_tool_restrictions = true;
380
381        // Create a temporary skill registry with only this skill
382        let temp_registry = Arc::new(SkillRegistry::new());
383        temp_registry.register(skill.clone())?;
384        skill_config.skill_registry = Some(temp_registry);
385
386        // Build the system prompt with skill content
387        skill_config.prompt_slots.role = Some(format!(
388            "You are executing the '{}' skill.\n\n{}\n\n{}",
389            skill.name, skill.description, skill.content
390        ));
391
392        let capability_subtask = ctx
393            .capability_context()
394            .map(|context| context.admit_subtask(format!("skill-{}", uuid::Uuid::new_v4()), false))
395            .transpose()?;
396        let cancellation = capability_subtask.as_ref().map_or_else(
397            || ctx.cancellation_token(),
398            crate::capability::AgentCapabilitySubtask::cancellation,
399        );
400
401        // Create the child Agent loop inside the Skill's spatial Subtask. Its
402        // provider/tool iterations will recursively create temporal Turns.
403        let tool_executor = self
404            .tool_executor
405            .resolve()
406            .ok_or_else(|| anyhow!("Skill tool executor is closed"))?;
407        let mut agent_loop = AgentLoop::new(
408            self.llm_client.clone(),
409            tool_executor,
410            ctx.clone(),
411            skill_config,
412        );
413        if let Some(subtask) = capability_subtask.as_ref() {
414            agent_loop = agent_loop.with_capability_runtime(subtask.runtime());
415        }
416
417        // Execute the skill with the prompt
418        let prompt = args
419            .prompt
420            .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
421
422        // The skill remains inside the owning Run's cancellation and budget
423        // scope. Its Subtask token is derived from the invoking Turn, so parent
424        // cancellation reaches every provider/tool call without allowing the
425        // child to cancel its parent.
426        let watch = crate::porcelain::Watch::start(&ctx.workspace).await;
427        let execution = agent_loop
428            .execute_with_session(
429                &[],
430                &prompt,
431                ctx.session_id.as_deref(),
432                None,
433                Some(&cancellation),
434            )
435            .await;
436        let cancelled = cancellation.is_cancelled();
437        let close = close_skill_capability_subtask(capability_subtask.as_ref()).await;
438        let changed = watch.finish(&ctx.workspace).await;
439        let result = match (execution, close) {
440            (Ok(result), Ok(())) => result,
441            (Ok(_), Err(close_error)) => {
442                return skill_effect_or_error(&skill.name, 0, &close_error.to_string(), &changed);
443            }
444            (Err(error), Ok(())) => {
445                return skill_effect_or_error(&skill.name, 0, &error.to_string(), &changed);
446            }
447            (Err(error), Err(close_error)) => {
448                tracing::warn!(
449                    error = %close_error,
450                    "Capability Skill Subtask close also failed after execution failure"
451                );
452                return skill_effect_or_error(&skill.name, 0, &error.to_string(), &changed);
453            }
454        };
455        if cancelled {
456            return skill_effect_or_error(
457                &skill.name,
458                result.tool_calls_count,
459                &format!("Skill '{}' cancelled by caller", skill.name),
460                &changed,
461            );
462        }
463
464        // A child narrative success is not a parent success if the skill
465        // wrote files. Publish the workspace delta so the parent gate opens.
466        Ok(skill_output(
467            &skill.name,
468            result.tool_calls_count,
469            result.text,
470            Some(result.usage),
471            &changed,
472        ))
473    }
474}
475
476fn skill_output(
477    skill_name: &str,
478    tool_calls: usize,
479    content: String,
480    usage: Option<crate::llm::TokenUsage>,
481    changed: &[String],
482) -> ToolOutput {
483    let mut metadata = serde_json::json!({
484        "skill_name": skill_name,
485        "tool_calls": tool_calls,
486    });
487    if let Some(usage) = usage {
488        if let Some(object) = metadata.as_object_mut() {
489            object.insert("usage".to_string(), serde_json::json!(usage));
490        }
491    }
492    let mut metadata = Some(metadata);
493    crate::porcelain::attach(&mut metadata, changed);
494    ToolOutput {
495        content,
496        success: true,
497        metadata,
498        images: Vec::new(),
499        trust: crate::tools::ToolResultTrustV1::WorkspaceData,
500        error_kind: None,
501    }
502}
503
504fn skill_effect_or_error(
505    skill_name: &str,
506    tool_calls: usize,
507    error: &str,
508    changed: &[String],
509) -> Result<ToolOutput> {
510    if changed.is_empty() {
511        anyhow::bail!("{error}");
512    }
513    Ok(skill_output(
514        skill_name,
515        tool_calls,
516        error.to_string(),
517        None,
518        changed,
519    ))
520}
521
522async fn close_skill_capability_subtask(
523    subtask: Option<&crate::capability::AgentCapabilitySubtask>,
524) -> Result<()> {
525    let Some(subtask) = subtask else {
526        return Ok(());
527    };
528    let report = subtask.close().await?;
529    if !report.is_clean() {
530        anyhow::bail!(
531            "Capability Skill Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
532            report.tasks_failed,
533            report.tasks_timed_out,
534            report.child_scopes_failed,
535            report.child_scopes_timed_out,
536            report.effects_failed,
537            report.effects_timed_out,
538        );
539    }
540    Ok(())
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::budget::{BudgetDecision, BudgetGuard};
547    use crate::llm::{
548        ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
549    };
550    use crate::skills::SkillKind;
551    use crate::tools::ToolContext;
552    use anyhow::Result;
553    use async_trait::async_trait;
554    use std::sync::atomic::{AtomicUsize, Ordering};
555    use std::sync::Mutex;
556    use std::time::Duration;
557    use tokio::sync::{mpsc, Notify};
558    use tokio_util::sync::CancellationToken;
559
560    struct MockLlmClient {
561        responses: Mutex<Vec<LlmResponse>>,
562    }
563
564    impl MockLlmClient {
565        fn new(responses: Vec<LlmResponse>) -> Self {
566            Self {
567                responses: Mutex::new(responses),
568            }
569        }
570
571        fn text_response(text: &str) -> LlmResponse {
572            LlmResponse {
573                message: Message {
574                    role: "assistant".to_string(),
575                    content: vec![ContentBlock::Text {
576                        text: text.to_string(),
577                    }],
578                    reasoning_content: None,
579                    transcript_text: None,
580                    transcript_visibility: Default::default(),
581                },
582                usage: TokenUsage {
583                    prompt_tokens: 10,
584                    completion_tokens: 5,
585                    total_tokens: 15,
586                    cache_read_tokens: None,
587                    cache_write_tokens: None,
588                },
589                stop_reason: Some("end_turn".to_string()),
590                token_logprobs: Vec::new(),
591                meta: None,
592            }
593        }
594
595        fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
596            LlmResponse {
597                message: Message {
598                    role: "assistant".to_string(),
599                    content: vec![ContentBlock::ToolUse {
600                        id: id.to_string(),
601                        name: name.to_string(),
602                        input,
603                    }],
604                    reasoning_content: None,
605                    transcript_text: None,
606                    transcript_visibility: Default::default(),
607                },
608                usage: TokenUsage {
609                    prompt_tokens: 10,
610                    completion_tokens: 5,
611                    total_tokens: 15,
612                    cache_read_tokens: None,
613                    cache_write_tokens: None,
614                },
615                stop_reason: Some("tool_use".to_string()),
616                token_logprobs: Vec::new(),
617                meta: None,
618            }
619        }
620    }
621
622    #[async_trait]
623    impl LlmClient for MockLlmClient {
624        async fn complete(
625            &self,
626            _messages: &[Message],
627            _system: Option<&str>,
628            _tools: &[ToolDefinition],
629        ) -> Result<LlmResponse> {
630            let mut responses = self.responses.lock().unwrap();
631            if responses.is_empty() {
632                anyhow::bail!("No more mock responses available");
633            }
634            Ok(responses.remove(0))
635        }
636
637        async fn complete_streaming(
638            &self,
639            _messages: &[Message],
640            _system: Option<&str>,
641            _tools: &[ToolDefinition],
642            _cancel_token: tokio_util::sync::CancellationToken,
643        ) -> Result<mpsc::Receiver<StreamEvent>> {
644            anyhow::bail!("streaming not used in SkillTool tests")
645        }
646    }
647
648    #[test]
649    fn skill_tool_does_not_retain_its_executor() {
650        let executor = Arc::new(ToolExecutor::new("skill-cycle-test".to_string()));
651        let lifetime = Arc::downgrade(&executor);
652        let _tool = SkillTool::new_registry_bound(
653            Arc::new(SkillRegistry::new()),
654            Arc::new(MockLlmClient::new(Vec::new())),
655            executor.clone(),
656            AgentConfig::default(),
657        );
658
659        drop(executor);
660
661        assert!(lifetime.upgrade().is_none());
662    }
663
664    #[derive(Default)]
665    struct SkillBudgetGuard {
666        checks: AtomicUsize,
667        records: AtomicUsize,
668        sessions: Mutex<Vec<String>>,
669    }
670
671    #[async_trait]
672    impl BudgetGuard for SkillBudgetGuard {
673        async fn check_before_llm(
674            &self,
675            session_id: &str,
676            _estimated_prompt_tokens: usize,
677        ) -> BudgetDecision {
678            self.checks.fetch_add(1, Ordering::SeqCst);
679            self.sessions.lock().unwrap().push(session_id.to_string());
680            BudgetDecision::Allow
681        }
682
683        async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
684            self.records.fetch_add(1, Ordering::SeqCst);
685        }
686    }
687
688    struct BlockingSkillClient {
689        started: Arc<Notify>,
690        calls: Arc<AtomicUsize>,
691    }
692
693    struct SkillSideEffectTool {
694        calls: Arc<AtomicUsize>,
695    }
696
697    struct SkillScopeProbeTool {
698        log: Arc<Mutex<Vec<String>>>,
699    }
700
701    struct SkillScopeProbeEffect {
702        scope_id: String,
703        log: Arc<Mutex<Vec<String>>>,
704    }
705
706    #[async_trait]
707    impl crate::capability::CapabilityEffect for SkillScopeProbeEffect {
708        fn name(&self) -> &str {
709            "test.skill.scope.effect"
710        }
711
712        async fn close(
713            self: Box<Self>,
714        ) -> std::result::Result<(), crate::capability::CapabilityEffectError> {
715            self.log
716                .lock()
717                .unwrap()
718                .push(format!("closed:{}", self.scope_id));
719            Ok(())
720        }
721    }
722
723    #[async_trait]
724    impl Tool for SkillScopeProbeTool {
725        fn name(&self) -> &str {
726            "skill_scope_probe"
727        }
728
729        fn description(&self) -> &str {
730            "Records the capability Turn owned by a nested Skill Agent"
731        }
732
733        fn parameters(&self) -> serde_json::Value {
734            serde_json::json!({"type": "object", "additionalProperties": false})
735        }
736
737        fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
738            crate::tools::ToolCapabilities::parallel_safe_read(1)
739        }
740
741        async fn execute(
742            &self,
743            _args: &serde_json::Value,
744            ctx: &ToolContext,
745        ) -> Result<ToolOutput> {
746            let scope_id = ctx
747                .capability_scope_id()
748                .ok_or_else(|| anyhow!("nested Skill Tool has no capability Turn"))?
749                .to_owned();
750            self.log
751                .lock()
752                .unwrap()
753                .push(format!("registered:{scope_id}"));
754            ctx.register_capability_effect(SkillScopeProbeEffect {
755                scope_id,
756                log: Arc::clone(&self.log),
757            })?;
758            Ok(ToolOutput::success("registered"))
759        }
760    }
761
762    #[async_trait]
763    impl Tool for SkillSideEffectTool {
764        fn name(&self) -> &str {
765            "side_effect"
766        }
767
768        fn description(&self) -> &str {
769            "Records a test-only side effect"
770        }
771
772        fn parameters(&self) -> serde_json::Value {
773            serde_json::json!({"type": "object", "additionalProperties": false})
774        }
775
776        async fn execute(
777            &self,
778            _args: &serde_json::Value,
779            _ctx: &ToolContext,
780        ) -> Result<ToolOutput> {
781            self.calls.fetch_add(1, Ordering::SeqCst);
782            Ok(ToolOutput::success("unexpected-side-effect"))
783        }
784    }
785
786    #[async_trait]
787    impl LlmClient for BlockingSkillClient {
788        async fn complete(
789            &self,
790            _messages: &[Message],
791            _system: Option<&str>,
792            _tools: &[ToolDefinition],
793        ) -> Result<LlmResponse> {
794            self.calls.fetch_add(1, Ordering::SeqCst);
795            self.started.notify_one();
796            std::future::pending::<Result<LlmResponse>>().await
797        }
798
799        async fn complete_streaming(
800            &self,
801            _messages: &[Message],
802            _system: Option<&str>,
803            _tools: &[ToolDefinition],
804            _cancel_token: CancellationToken,
805        ) -> Result<mpsc::Receiver<StreamEvent>> {
806            anyhow::bail!("streaming not used in SkillTool cancellation tests")
807        }
808    }
809
810    fn test_skill_registry() -> Arc<SkillRegistry> {
811        let registry = Arc::new(SkillRegistry::new());
812        registry.register_unchecked(Arc::new(Skill {
813            name: "test-skill".to_string(),
814            description: "Run a focused skill".to_string(),
815            allowed_tools: None,
816            disable_model_invocation: false,
817            kind: SkillKind::Instruction,
818            content: "Reply with the skill result.".to_string(),
819            tags: vec!["focus".to_string()],
820            version: None,
821        }));
822        registry
823    }
824
825    #[test]
826    fn test_skill_permission_policy() {
827        let skill = Skill {
828            name: "test-skill".to_string(),
829            description: "Test".to_string(),
830            allowed_tools: Some("read(*), grep(*)".to_string()),
831            disable_model_invocation: false,
832            kind: SkillKind::Instruction,
833            content: String::new(),
834            tags: Vec::new(),
835            version: None,
836        };
837
838        let policy = SkillTool::create_skill_permission_policy(&skill);
839
840        // Should allow tools in allowed-tools
841        assert_eq!(
842            policy.check("read", &serde_json::json!({})),
843            PermissionDecision::Allow
844        );
845        assert_eq!(
846            policy.check(
847                "search",
848                &serde_json::json!({"mode": "grep", "query": "TODO"}),
849            ),
850            PermissionDecision::Allow
851        );
852
853        // Should deny tools not in allowed-tools
854        assert_eq!(
855            policy.check("write", &serde_json::json!({})),
856            PermissionDecision::Deny
857        );
858    }
859
860    #[test]
861    fn test_skill_permission_policy_denies_when_unspecified() {
862        let skill = Skill {
863            name: "test-skill".to_string(),
864            description: "Test".to_string(),
865            allowed_tools: None,
866            disable_model_invocation: false,
867            kind: SkillKind::Instruction,
868            content: String::new(),
869            tags: Vec::new(),
870            version: None,
871        };
872
873        let policy = SkillTool::create_skill_permission_policy(&skill);
874
875        assert_eq!(
876            policy.check("bash", &serde_json::json!({"command": "python --version"})),
877            PermissionDecision::Deny
878        );
879        assert_eq!(
880            policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
881            PermissionDecision::Deny
882        );
883    }
884
885    #[test]
886    fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
887        let skill = Skill {
888            name: "test-skill".to_string(),
889            description: "Test".to_string(),
890            allowed_tools: Some("Read Write Edit Bash".to_string()),
891            disable_model_invocation: false,
892            kind: SkillKind::Instruction,
893            content: String::new(),
894            tags: Vec::new(),
895            version: None,
896        };
897
898        let policy = SkillTool::create_skill_permission_policy(&skill);
899
900        assert_eq!(
901            policy.check("bash", &serde_json::json!({"command": "python --version"})),
902            PermissionDecision::Allow
903        );
904        assert_eq!(
905            policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
906            PermissionDecision::Deny
907        );
908    }
909
910    #[test]
911    fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
912        let skill = Skill {
913            name: "test-skill".to_string(),
914            description: "Test".to_string(),
915            allowed_tools: Some("*".to_string()),
916            disable_model_invocation: false,
917            kind: SkillKind::Instruction,
918            content: String::new(),
919            tags: Vec::new(),
920            version: None,
921        };
922
923        let policy = SkillTool::create_skill_permission_policy(&skill);
924
925        assert_eq!(
926            policy.check("bash", &serde_json::json!({"command": "python --version"})),
927            PermissionDecision::Allow
928        );
929        assert_eq!(
930            policy.check("parallel_task", &serde_json::json!({"tasks": []})),
931            PermissionDecision::Allow
932        );
933    }
934
935    #[test]
936    fn test_skill_args_accepts_documented_shape() {
937        let args =
938            SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
939        assert_eq!(args.skill_name, "code-review");
940        assert_eq!(args.prompt, None);
941    }
942
943    #[test]
944    fn test_skill_args_accepts_common_aliases_and_wrappers() {
945        let camel =
946            SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
947        assert_eq!(camel.skill_name, "code-review");
948
949        let name = SkillArgs::from_tool_args(&serde_json::json!({
950            "name": "code-review",
951            "query": "review this patch"
952        }))
953        .unwrap();
954        assert_eq!(name.skill_name, "code-review");
955        assert_eq!(name.prompt.as_deref(), Some("review this patch"));
956
957        let nested = SkillArgs::from_tool_args(&serde_json::json!({
958            "input": {
959                "skill_name": "code-review",
960                "prompt": "review this patch"
961            }
962        }))
963        .unwrap();
964        assert_eq!(nested.skill_name, "code-review");
965        assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
966
967        let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
968        assert_eq!(direct.skill_name, "code-review");
969    }
970
971    #[test]
972    fn test_skill_args_missing_skill_name_errors() {
973        let err =
974            SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
975        assert!(err.to_string().contains("missing field 'skill_name'"));
976    }
977
978    #[test]
979    fn test_search_skills_args_accepts_string_and_object() {
980        let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
981        assert_eq!(direct.query, "review code");
982        assert_eq!(direct.limit, None);
983
984        let object =
985            SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
986                .unwrap();
987        assert_eq!(object.query, "review");
988        assert_eq!(object.limit, Some(2));
989    }
990
991    #[tokio::test]
992    async fn test_search_skills_tool_returns_matching_skills() {
993        let hermetic_root = crate::test_support::hermetic_workspace();
994        let registry = Arc::new(SkillRegistry::new());
995        registry.register_unchecked(Arc::new(Skill {
996            name: "code-review".to_string(),
997            description: "Review code changes".to_string(),
998            allowed_tools: Some("read(*), grep(*)".to_string()),
999            disable_model_invocation: false,
1000            kind: SkillKind::Instruction,
1001            content: "Review instructions".to_string(),
1002            tags: vec!["review".to_string()],
1003            version: None,
1004        }));
1005
1006        let tool = SearchSkillsTool::new(registry);
1007        let result = tool
1008            .execute(
1009                &serde_json::json!({"query": "review"}),
1010                &ToolContext::new(hermetic_root.clone()),
1011            )
1012            .await
1013            .unwrap();
1014
1015        assert!(result.success);
1016        assert!(result.content.contains("code-review"));
1017        assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
1018    }
1019
1020    #[tokio::test]
1021    async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
1022        let hermetic_root = crate::test_support::hermetic_workspace();
1023        let registry = Arc::new(SkillRegistry::new());
1024        for index in 0..25 {
1025            registry.register_unchecked(Arc::new(Skill {
1026                name: format!("review-{index:02}"),
1027                description: "Review code changes".to_string(),
1028                allowed_tools: Some("read(*)".to_string()),
1029                disable_model_invocation: false,
1030                kind: SkillKind::Instruction,
1031                content: "Review instructions".to_string(),
1032                tags: vec!["review".to_string()],
1033                version: None,
1034            }));
1035        }
1036        registry.register_unchecked(Arc::new(Skill {
1037            name: "review-persona".to_string(),
1038            description: "Review persona".to_string(),
1039            allowed_tools: None,
1040            disable_model_invocation: false,
1041            kind: SkillKind::Persona,
1042            content: "Persona instructions".to_string(),
1043            tags: vec!["review".to_string()],
1044            version: None,
1045        }));
1046
1047        let tool = SearchSkillsTool::new(registry);
1048        let result = tool
1049            .execute(
1050                &serde_json::json!({"query": "review", "limit": 100}),
1051                &ToolContext::new(hermetic_root.clone()),
1052            )
1053            .await
1054            .unwrap();
1055
1056        let metadata = result.metadata.unwrap();
1057        let skills = metadata["skills"].as_array().unwrap();
1058        assert_eq!(skills.len(), 20);
1059        assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
1060    }
1061
1062    #[test]
1063    fn test_skill_tool_schema_enforces_canonical_shape() {
1064        let hermetic_root = crate::test_support::hermetic_workspace();
1065        let registry = Arc::new(SkillRegistry::new());
1066        let llm = Arc::new(MockLlmClient::new(vec![]));
1067        let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1068        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
1069
1070        let params = tool.parameters();
1071        assert_eq!(params["type"], "object");
1072        assert_eq!(params["additionalProperties"], serde_json::json!(false));
1073        assert_eq!(params["required"], serde_json::json!(["skill_name"]));
1074
1075        let examples = params["examples"].as_array().unwrap();
1076        assert_eq!(examples[0]["skill_name"], "code-review");
1077        assert!(examples[0].get("name").is_none());
1078        assert!(examples[0].get("skillName").is_none());
1079    }
1080
1081    #[tokio::test]
1082    async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
1083        let hermetic_root = crate::test_support::hermetic_workspace();
1084        use crate::prompts::PlanningMode;
1085
1086        let registry = Arc::new(SkillRegistry::new());
1087        registry.register_unchecked(Arc::new(Skill {
1088            name: "test-skill".to_string(),
1089            description: "Run a focused skill".to_string(),
1090            allowed_tools: None,
1091            disable_model_invocation: false,
1092            kind: SkillKind::Instruction,
1093            content: "Reply with the skill result.".to_string(),
1094            tags: vec!["focus".to_string()],
1095            version: None,
1096        }));
1097
1098        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1099            "skill completed",
1100        )]));
1101        let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1102        // Disable planning mode since the mock only has one response
1103        let config = AgentConfig {
1104            planning_mode: PlanningMode::Disabled,
1105            continuation_enabled: false,
1106            ..Default::default()
1107        };
1108        let tool = SkillTool::new(registry, llm, executor, config);
1109
1110        let result = tool
1111            .execute(
1112                &serde_json::json!({
1113                    "skill_name": "test-skill",
1114                    "prompt": "verify the skill result"
1115                }),
1116                &ToolContext::new(hermetic_root.clone()),
1117            )
1118            .await
1119            .unwrap();
1120
1121        assert!(result.success);
1122        assert_eq!(result.content, "skill completed");
1123        let metadata = result.metadata.unwrap();
1124        assert_eq!(metadata["skill_name"], "test-skill");
1125        assert_eq!(metadata["tool_calls"], 0);
1126    }
1127
1128    #[tokio::test]
1129    async fn review_skill_invocation_returns_fences_instead_of_not_found() {
1130        let hermetic_root = crate::test_support::hermetic_workspace();
1131        use crate::prompts::PlanningMode;
1132
1133        let registry = Arc::new(SkillRegistry::new());
1134        registry.register_host(Arc::new(Skill {
1135            name: "review".to_string(),
1136            description: "Single-document review".to_string(),
1137            allowed_tools: None,
1138            disable_model_invocation: false,
1139            kind: SkillKind::Instruction,
1140            content: "Emit fences for the injected batch only.".to_string(),
1141            tags: vec![],
1142            version: None,
1143        }));
1144
1145        let fence = "```a3s-review-finding\n{\"id\":\"f1\",\"title\":\"核对\",\"evidenceQuote\":\"交付前必须核对证据\",\"summary\":\"缺少核对\",\"guidance\":\"写明核对人\"}\n```";
1146        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1147            fence,
1148        )]));
1149        let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1150        let config = AgentConfig {
1151            planning_mode: PlanningMode::Disabled,
1152            continuation_enabled: false,
1153            ..Default::default()
1154        };
1155        let tool = SkillTool::new(registry, llm, executor, config);
1156        let result = tool
1157            .execute(
1158                &serde_json::json!({
1159                    "skill_name": "review",
1160                    "prompt": "审查本批注入正文"
1161                }),
1162                &ToolContext::new(hermetic_root.clone()),
1163            )
1164            .await
1165            .unwrap();
1166
1167        assert!(result.success);
1168        assert!(result.content.contains("a3s-review-finding"));
1169        assert!(!result.content.contains("not found"));
1170    }
1171
1172    #[tokio::test]
1173    async fn skill_workspace_write_is_a_parent_mutation() {
1174        use crate::prompts::PlanningMode;
1175
1176        let workspace = tempfile::tempdir().unwrap();
1177        let registry = Arc::new(SkillRegistry::new());
1178        registry.register_unchecked(Arc::new(Skill {
1179            name: "writer-skill".to_string(),
1180            description: "Write a file then narrate".to_string(),
1181            allowed_tools: Some("skill_workspace_writer".to_string()),
1182            disable_model_invocation: false,
1183            kind: SkillKind::Instruction,
1184            content: "Write the file, then finish.".to_string(),
1185            tags: Vec::new(),
1186            version: None,
1187        }));
1188        let executor = Arc::new(ToolExecutor::new(
1189            workspace.path().to_string_lossy().into_owned(),
1190        ));
1191        executor.register_dynamic_tool(Arc::new(SkillWorkspaceWriter));
1192        let tool = SkillTool::new(
1193            registry,
1194            Arc::new(MockLlmClient::new(vec![
1195                MockLlmClient::tool_call_response(
1196                    "write-call",
1197                    "skill_workspace_writer",
1198                    serde_json::json!({}),
1199                ),
1200                MockLlmClient::text_response("done"),
1201            ])),
1202            executor,
1203            AgentConfig {
1204                planning_mode: PlanningMode::Disabled,
1205                continuation_enabled: false,
1206                ..Default::default()
1207            },
1208        );
1209        let result = tool
1210            .execute(
1211                &serde_json::json!({"skill_name": "writer-skill"}),
1212                &ToolContext::new(workspace.path().to_path_buf()).with_session_id("skill-write"),
1213            )
1214            .await
1215            .unwrap();
1216
1217        assert!(
1218            workspace.path().join("guest.txt").is_file(),
1219            "skill child did not write"
1220        );
1221        let metadata = result.metadata.expect("skill metadata");
1222        let paths = metadata["changed_paths"].as_array().expect("changed_paths");
1223        assert!(
1224            paths.iter().any(|path| path == "guest.txt"),
1225            "skill hid a workspace write: {metadata}"
1226        );
1227        let mut ledger = crate::harness_loop::MutationLedger::default();
1228        ledger.observe_tool("skill", 0, Some(&metadata));
1229        assert!(
1230            !ledger.is_empty(),
1231            "parent completion gate did not see the skill write"
1232        );
1233    }
1234
1235    struct SkillWorkspaceWriter;
1236
1237    #[async_trait]
1238    impl Tool for SkillWorkspaceWriter {
1239        fn name(&self) -> &str {
1240            "skill_workspace_writer"
1241        }
1242
1243        fn description(&self) -> &str {
1244            "Writes a workspace file without publishing a mutation path"
1245        }
1246
1247        fn parameters(&self) -> serde_json::Value {
1248            serde_json::json!({"type": "object", "additionalProperties": false})
1249        }
1250
1251        fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
1252            crate::tools::ToolCapabilities::parallel_safe_read(1)
1253        }
1254
1255        async fn execute(
1256            &self,
1257            _args: &serde_json::Value,
1258            ctx: &ToolContext,
1259        ) -> Result<ToolOutput> {
1260            std::fs::write(ctx.workspace.join("guest.txt"), "hello\n")?;
1261            Ok(ToolOutput::success("wrote"))
1262        }
1263    }
1264
1265    #[tokio::test]
1266    async fn skill_child_agent_owns_recursive_subtask_and_turn_scopes() {
1267        use crate::prompts::PlanningMode;
1268
1269        let workspace = tempfile::tempdir().unwrap();
1270        let registry = Arc::new(SkillRegistry::new());
1271        registry.register_unchecked(Arc::new(Skill {
1272            name: "scoped-skill".to_string(),
1273            description: "Exercise nested scope ownership".to_string(),
1274            allowed_tools: Some("skill_scope_probe".to_string()),
1275            disable_model_invocation: false,
1276            kind: SkillKind::Instruction,
1277            content: "Call skill_scope_probe once, then finish.".to_string(),
1278            tags: Vec::new(),
1279            version: None,
1280        }));
1281        let log = Arc::new(Mutex::new(Vec::new()));
1282        let executor = Arc::new(ToolExecutor::new(
1283            workspace.path().to_string_lossy().into_owned(),
1284        ));
1285        executor.register_dynamic_tool(Arc::new(SkillScopeProbeTool {
1286            log: Arc::clone(&log),
1287        }));
1288        let tool = SkillTool::new(
1289            registry,
1290            Arc::new(MockLlmClient::new(vec![
1291                MockLlmClient::tool_call_response(
1292                    "skill-probe",
1293                    "skill_scope_probe",
1294                    serde_json::json!({}),
1295                ),
1296                MockLlmClient::text_response("skill complete"),
1297            ])),
1298            executor,
1299            AgentConfig {
1300                planning_mode: PlanningMode::Disabled,
1301                continuation_enabled: false,
1302                ..Default::default()
1303            },
1304        );
1305        let set = crate::capability::CapabilitySet::empty().unwrap();
1306        let ceiling = crate::capability::CapabilityCeiling::all(
1307            &set,
1308            crate::capability::WorkspaceCapabilityCeiling::all(),
1309            crate::capability::GovernanceCapabilityCeiling::none_required(),
1310            crate::capability::CapabilityExecutionCeiling::new(
1311                4,
1312                2,
1313                Some(1_000),
1314                Some(1_000),
1315                Some(5_000),
1316            )
1317            .unwrap(),
1318        )
1319        .unwrap();
1320        let session =
1321            crate::capability::CapabilityScope::<crate::capability::Session>::new_session(
1322                "session-1",
1323                set,
1324                ceiling.clone(),
1325            )
1326            .unwrap();
1327        let run = session.admit_run("run-1", ceiling).unwrap();
1328        let runtime = crate::capability::AgentCapabilityRuntime::from_run(&run);
1329        let parent_turn = runtime.begin_turn(1).unwrap();
1330        let capability_context = parent_turn.tool_context();
1331        let parent_scope_id = capability_context.foreground_scope_id().to_owned();
1332        let context = ToolContext::new(workspace.path().to_path_buf())
1333            .with_cancellation(parent_turn.cancellation())
1334            .with_capability_context(capability_context);
1335
1336        let result = tool
1337            .execute(&serde_json::json!({"skill_name": "scoped-skill"}), &context)
1338            .await
1339            .unwrap();
1340
1341        assert!(result.success, "{}", result.content);
1342        let entries = log.lock().unwrap().clone();
1343        assert_eq!(entries.len(), 2, "{entries:?}");
1344        let registered = entries[0].strip_prefix("registered:").unwrap();
1345        let closed = entries[1].strip_prefix("closed:").unwrap();
1346        assert_eq!(registered, closed);
1347        assert!(
1348            registered.starts_with(&format!("{parent_scope_id}/subtask/skill-")),
1349            "{registered}"
1350        );
1351        assert!(registered.contains("/turn/turn-1-1"), "{registered}");
1352
1353        parent_turn.close().await.unwrap();
1354        run.close().await.unwrap();
1355        session.close().await.unwrap();
1356    }
1357
1358    #[tokio::test]
1359    async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
1360        use crate::prompts::PlanningMode;
1361
1362        let workspace = tempfile::tempdir().unwrap();
1363        let registry = Arc::new(SkillRegistry::new());
1364        registry.register_unchecked(Arc::new(Skill {
1365            name: "bash-skill".to_string(),
1366            description: "Attempt a shell call".to_string(),
1367            allowed_tools: Some("bash(*)".to_string()),
1368            disable_model_invocation: false,
1369            kind: SkillKind::Instruction,
1370            content: "Use Bash once.".to_string(),
1371            tags: Vec::new(),
1372            version: None,
1373        }));
1374        let llm = Arc::new(MockLlmClient::new(vec![
1375            MockLlmClient::tool_call_response(
1376                "bash-call",
1377                "bash",
1378                serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
1379            ),
1380            MockLlmClient::text_response("The host boundary rejected the call."),
1381        ]));
1382        let parent_policy = PermissionPolicy::new().deny("bash(*)");
1383        let config = AgentConfig {
1384            planning_mode: PlanningMode::Disabled,
1385            continuation_enabled: false,
1386            permission_checker: Some(Arc::new(parent_policy.clone())),
1387            permission_policy: Some(parent_policy),
1388            ..Default::default()
1389        };
1390        let tool = SkillTool::new(
1391            registry,
1392            llm,
1393            Arc::new(ToolExecutor::new(
1394                workspace.path().to_string_lossy().into_owned(),
1395            )),
1396            config,
1397        );
1398
1399        let result = tool
1400            .execute(
1401                &serde_json::json!({"skill_name": "bash-skill"}),
1402                &ToolContext::new(workspace.path().to_path_buf()),
1403            )
1404            .await
1405            .unwrap();
1406
1407        assert!(result.success, "{}", result.content);
1408        assert!(
1409            !workspace.path().join("skill-boundary-leak").exists(),
1410            "a skill-local allow-list must not bypass the parent host boundary"
1411        );
1412    }
1413
1414    #[tokio::test]
1415    async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
1416        use crate::prompts::PlanningMode;
1417
1418        struct RecordingBoundary {
1419            checked: Arc<Mutex<Vec<String>>>,
1420        }
1421
1422        impl crate::permissions::PermissionChecker for RecordingBoundary {
1423            fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
1424                self.checked.lock().unwrap().push(tool_name.to_string());
1425                if tool_name == "side_effect" {
1426                    PermissionDecision::Deny
1427                } else {
1428                    PermissionDecision::Allow
1429                }
1430            }
1431        }
1432
1433        let workspace = tempfile::tempdir().unwrap();
1434        let calls = Arc::new(AtomicUsize::new(0));
1435        let checked = Arc::new(Mutex::new(Vec::new()));
1436        let registry = Arc::new(SkillRegistry::new());
1437        registry.register_unchecked(Arc::new(Skill {
1438            name: "orchestrator-skill".to_string(),
1439            description: "Exercise governed orchestrators".to_string(),
1440            allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
1441            disable_model_invocation: false,
1442            kind: SkillKind::Instruction,
1443            content: "Run the requested orchestration.".to_string(),
1444            tags: Vec::new(),
1445            version: None,
1446        }));
1447        let llm = Arc::new(MockLlmClient::new(vec![
1448            MockLlmClient::tool_call_response(
1449                "model-batch",
1450                "batch",
1451                serde_json::json!({
1452                    "invocations": [{
1453                        "tool": "program",
1454                        "args": {
1455                            "type": "script",
1456                            "language": "javascript",
1457                            "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
1458                            "allowed_tools": ["side_effect"]
1459                        }
1460                    }]
1461                }),
1462            ),
1463            MockLlmClient::text_response("The boundary held."),
1464        ]));
1465        let executor = Arc::new(ToolExecutor::new(
1466            workspace.path().to_string_lossy().into_owned(),
1467        ));
1468        executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1469            calls: Arc::clone(&calls),
1470        }));
1471        let tool = SkillTool::new(
1472            registry,
1473            llm,
1474            executor,
1475            AgentConfig {
1476                planning_mode: PlanningMode::Disabled,
1477                continuation_enabled: false,
1478                permission_checker: Some(Arc::new(RecordingBoundary {
1479                    checked: Arc::clone(&checked),
1480                })),
1481                ..Default::default()
1482            },
1483        );
1484        let context = ToolContext::new(workspace.path().to_path_buf())
1485            .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1486
1487        let result = tool
1488            .execute(
1489                &serde_json::json!({"skill_name": "orchestrator-skill"}),
1490                &context,
1491            )
1492            .await
1493            .unwrap();
1494
1495        assert!(result.success, "{}", result.content);
1496        assert_eq!(result.content, "The boundary held.");
1497        assert_eq!(calls.load(Ordering::SeqCst), 0);
1498        let checked = checked.lock().unwrap();
1499        for expected in ["batch", "program", "side_effect"] {
1500            assert!(
1501                checked.iter().any(|tool| tool == expected),
1502                "{expected} must cross the parent permission boundary: {checked:?}"
1503            );
1504        }
1505    }
1506
1507    #[tokio::test]
1508    async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1509        let hermetic_root = crate::test_support::hermetic_workspace();
1510        use crate::prompts::PlanningMode;
1511
1512        let guard = Arc::new(SkillBudgetGuard::default());
1513        let config = AgentConfig {
1514            planning_mode: PlanningMode::Disabled,
1515            continuation_enabled: false,
1516            budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1517            ..Default::default()
1518        };
1519        let tool = SkillTool::new(
1520            test_skill_registry(),
1521            Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1522                "skill completed",
1523            )])),
1524            Arc::new(ToolExecutor::new(hermetic_root.display().to_string())),
1525            config,
1526        );
1527        let ctx = ToolContext::new(hermetic_root.clone()).with_session_id("parent-session");
1528
1529        let result = tool
1530            .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1531            .await
1532            .unwrap();
1533
1534        assert!(result.success);
1535        assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1536        assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1537        assert_eq!(
1538            guard.sessions.lock().unwrap().as_slice(),
1539            &["parent-session".to_string()]
1540        );
1541    }
1542
1543    #[tokio::test]
1544    async fn skill_child_llm_call_stops_on_parent_cancellation() {
1545        let hermetic_root = crate::test_support::hermetic_workspace();
1546        use crate::prompts::PlanningMode;
1547
1548        let started = Arc::new(Notify::new());
1549        let calls = Arc::new(AtomicUsize::new(0));
1550        let tool = SkillTool::new(
1551            test_skill_registry(),
1552            Arc::new(BlockingSkillClient {
1553                started: Arc::clone(&started),
1554                calls: Arc::clone(&calls),
1555            }),
1556            Arc::new(ToolExecutor::new(hermetic_root.display().to_string())),
1557            AgentConfig {
1558                planning_mode: PlanningMode::Disabled,
1559                continuation_enabled: false,
1560                ..Default::default()
1561            },
1562        );
1563        let cancellation = CancellationToken::new();
1564        let ctx = ToolContext::new(hermetic_root.clone())
1565            .with_session_id("parent-session")
1566            .with_cancellation(cancellation.clone());
1567        let started_wait = started.notified();
1568        let run = tokio::spawn(async move {
1569            tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1570                .await
1571        });
1572
1573        tokio::time::timeout(Duration::from_secs(1), started_wait)
1574            .await
1575            .expect("skill provider call should start");
1576        cancellation.cancel();
1577        let error = tokio::time::timeout(Duration::from_secs(1), run)
1578            .await
1579            .expect("parent cancellation must stop the skill child")
1580            .expect("skill join should succeed")
1581            .expect_err("cancelled skill must not return success");
1582
1583        assert!(error.to_string().contains("cancelled"));
1584        assert_eq!(calls.load(Ordering::SeqCst), 1);
1585    }
1586
1587    #[tokio::test]
1588    async fn test_skill_tool_execute_errors_for_unknown_skill() {
1589        let hermetic_root = crate::test_support::hermetic_workspace();
1590        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1591            "unused",
1592        )]));
1593        let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1594        let tool = SkillTool::new(
1595            Arc::new(SkillRegistry::new()),
1596            llm,
1597            executor,
1598            AgentConfig::default(),
1599        );
1600
1601        let err = tool
1602            .execute(
1603                &serde_json::json!({"skill_name": "missing-skill"}),
1604                &ToolContext::new(hermetic_root.clone()),
1605            )
1606            .await
1607            .unwrap_err();
1608
1609        assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1610    }
1611
1612    #[tokio::test]
1613    async fn skill_tool_rejects_disable_model_invocation_skills() {
1614        let hermetic_root = crate::test_support::hermetic_workspace();
1615        let registry = Arc::new(SkillRegistry::new());
1616        registry.register_unchecked(Arc::new(Skill {
1617            name: "host-only".to_string(),
1618            description: "Not for the model".to_string(),
1619            allowed_tools: Some("read(*)".to_string()),
1620            disable_model_invocation: true,
1621            kind: SkillKind::Instruction,
1622            content: "Secret host skill.".to_string(),
1623            tags: Vec::new(),
1624            version: None,
1625        }));
1626
1627        let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1628            "unused",
1629        )]));
1630        let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1631        let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
1632
1633        let err = tool
1634            .execute(
1635                &serde_json::json!({"skill_name": "host-only"}),
1636                &ToolContext::new(hermetic_root.clone()),
1637            )
1638            .await
1639            .unwrap_err();
1640
1641        assert!(err.to_string().contains("disables model invocation"));
1642    }
1643}