Skip to main content

vtcode_core/skills/
executor.rs

1//! Skill execution as Tool trait implementation
2//!
3//! Bridges Agent Skills to VT Code's tool system by implementing the Tool trait
4//! for skills, enabling them to execute with full access to VT Code's permissions,
5//! caching, and audit systems.
6//!
7//! ## LLM Sub-Calls (Phase 5)
8//!
9//! Skills can now execute with full LLM support via `execute_skill_with_sub_llm()`:
10//! 1. Skill instructions become the system prompt
11//! 2. User input is the first message
12//! 3. All available tools are passed to the LLM
13//! 4. Tool calls are executed and results are fed back
14//! 5. Final response is returned
15
16use crate::config::VTCodeConfig;
17use crate::config::models::ModelId;
18use crate::core::agent::runner::{AgentRunner, RunnerSettings};
19use crate::core::agent::task::Task;
20use crate::core::agent::types::AgentType;
21use crate::core::loop_detector::LoopDetector;
22use crate::llm::provider::{FinishReason, LLMProvider, LLMRequest, Message, ToolDefinition};
23use crate::sandboxing::{AdditionalPermissions, SandboxPermissions};
24use crate::skills::types::{Skill, SkillNetworkPolicy};
25use crate::tool_policy::ToolPolicy;
26use crate::tools::ToolRegistry;
27use crate::tools::registry::{ToolErrorType, ToolExecutionError};
28use crate::tools::tool_intent;
29use anyhow::{Context, Result, anyhow};
30use async_trait::async_trait;
31use chrono::Utc;
32use serde_json::{Map, Value};
33use std::borrow::Cow;
34use std::collections::BTreeSet;
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use std::time::Duration;
38use tracing::{debug, info, warn};
39use vtcode_config::auth::OpenAIChatGptAuthHandle;
40
41type SkillToolArgTransform = dyn Fn(&str, Value) -> Value + Send + Sync;
42
43const EMPTY_SKILL_INPUT_PROMPT: &str = "No explicit user input was provided. Follow the skill instructions using their default behavior for empty input.";
44const SKILL_TOOL_FREE_SYNTHESIS_PROMPT: &str = "Do not make any more tool calls. Provide the best final answer you can using the information already gathered.";
45const MAX_SKILL_LLM_ITERATIONS: usize = 10;
46
47fn skill_tool_free_synthesis_prompt(reason: &str) -> String {
48    format!("{reason}\n\n{SKILL_TOOL_FREE_SYNTHESIS_PROMPT}")
49}
50
51fn should_force_tool_free_synthesis(error: &ToolExecutionError) -> bool {
52    matches!(error.error_type, ToolErrorType::ToolNotFound)
53}
54
55/// Network-capable tool names that should be filtered based on skill network policy
56const NETWORK_TOOLS: &[&str] = &[
57    "http",
58    "fetch",
59    "browser",
60    "web_search",
61    "read_web_page",
62    "curl",
63];
64
65fn is_function_network_tool(tool: &ToolDefinition) -> bool {
66    tool.function.as_ref().is_some_and(|function| {
67        let name = function.name.to_ascii_lowercase();
68        NETWORK_TOOLS
69            .iter()
70            .any(|candidate| name.contains(candidate))
71    })
72}
73
74fn is_native_web_search_tool(tool: &ToolDefinition) -> bool {
75    matches!(tool.tool_type.as_str(), "web_search" | "google_search")
76        || tool.tool_type.starts_with("web_search_")
77}
78
79fn is_gemini_native_network_tool(tool: &ToolDefinition) -> bool {
80    matches!(tool.tool_type.as_str(), "google_maps" | "url_context")
81}
82
83fn is_network_capable_tool(tool: &ToolDefinition) -> bool {
84    is_native_web_search_tool(tool)
85        || is_gemini_native_network_tool(tool)
86        || is_function_network_tool(tool)
87}
88
89fn json_string_array(config: &Map<String, Value>, key: &str) -> Result<Option<Vec<String>>> {
90    let Some(value) = config.get(key) else {
91        return Ok(None);
92    };
93    let Value::Array(values) = value else {
94        return Err(anyhow!("{key} must be an array of strings"));
95    };
96
97    values
98        .iter()
99        .map(|value| {
100            value
101                .as_str()
102                .map(ToOwned::to_owned)
103                .ok_or_else(|| anyhow!("{key} must contain only strings"))
104        })
105        .collect::<Result<Vec<_>>>()
106        .map(Some)
107}
108
109fn set_json_string_array(config: &mut Map<String, Value>, key: &str, values: Vec<String>) {
110    if values.is_empty() {
111        config.remove(key);
112        return;
113    }
114
115    config.insert(
116        key.to_string(),
117        Value::Array(values.into_iter().map(Value::String).collect()),
118    );
119}
120
121fn intersect_domains(existing: Option<Vec<String>>, requested: &[String]) -> Vec<String> {
122    match existing {
123        Some(existing) => existing
124            .into_iter()
125            .filter(|domain| requested.iter().any(|candidate| candidate == domain))
126            .collect(),
127        None => requested.to_vec(),
128    }
129}
130
131fn union_domains(existing: Option<Vec<String>>, requested: &[String]) -> Vec<String> {
132    let mut merged = existing.unwrap_or_default();
133    for domain in requested {
134        if !merged.iter().any(|candidate| candidate == domain) {
135            merged.push(domain.clone());
136        }
137    }
138    merged
139}
140
141fn apply_web_search_policy(
142    skill: &Skill,
143    tool: &ToolDefinition,
144    policy: &SkillNetworkPolicy,
145) -> Option<ToolDefinition> {
146    let mut updated = tool.clone();
147    let existing_config = match updated.web_search.take() {
148        Some(Value::Object(config)) => config,
149        Some(_) => {
150            warn!(
151                skill = skill.name(),
152                tool_type = %tool.tool_type,
153                "Dropping network tool because web search policy could not be encoded"
154            );
155            return None;
156        }
157        None => Map::new(),
158    };
159
160    let existing_allowed = match json_string_array(&existing_config, "allowed_domains") {
161        Ok(value) => value,
162        Err(error) => {
163            warn!(
164                skill = skill.name(),
165                tool_type = %tool.tool_type,
166                error = %error,
167                "Dropping network tool because web search policy could not be encoded"
168            );
169            return None;
170        }
171    };
172    let existing_blocked = match json_string_array(&existing_config, "blocked_domains") {
173        Ok(value) => value,
174        Err(error) => {
175            warn!(
176                skill = skill.name(),
177                tool_type = %tool.tool_type,
178                error = %error,
179                "Dropping network tool because web search policy could not be encoded"
180            );
181            return None;
182        }
183    };
184    let merged_allowed = if policy.allowed_domains.is_empty() {
185        existing_allowed.unwrap_or_default()
186    } else {
187        intersect_domains(existing_allowed, &policy.allowed_domains)
188    };
189    let merged_blocked = if policy.denied_domains.is_empty() {
190        existing_blocked.unwrap_or_default()
191    } else {
192        union_domains(existing_blocked, &policy.denied_domains)
193    };
194
195    if updated.is_anthropic_web_search() && !merged_allowed.is_empty() && !merged_blocked.is_empty()
196    {
197        warn!(
198            skill = skill.name(),
199            tool_type = %tool.tool_type,
200            "Dropping anthropic web search tool because allowlist and denylist cannot both be enforced"
201        );
202        return None;
203    }
204
205    let mut config = existing_config;
206    set_json_string_array(&mut config, "allowed_domains", merged_allowed);
207    set_json_string_array(&mut config, "blocked_domains", merged_blocked);
208    updated.web_search = Some(Value::Object(config));
209
210    if let Err(error) = updated.validate() {
211        warn!(
212            skill = skill.name(),
213            tool_type = %tool.tool_type,
214            error = %error,
215            "Dropping network tool because the enforced web search policy is invalid"
216        );
217        return None;
218    }
219
220    Some(updated)
221}
222
223/// Filter available tools based on skill's network policy
224///
225/// - If skill has no network policy: remove network-capable tools
226/// - If skill has a network policy: enforce it for native web search tools
227/// - If the policy cannot be encoded safely: remove the tool
228pub fn filter_tools_for_skill(skill: &Skill, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
229    let network_policy = &skill.manifest.network_policy;
230
231    match network_policy {
232        None => tools
233            .into_iter()
234            .filter(|t| {
235                let is_network = is_network_capable_tool(t);
236                if is_network {
237                    debug!(
238                        tool = t.function_name(),
239                        "Filtered network tool for skill '{}' (no network policy)",
240                        skill.name()
241                    );
242                }
243                !is_network
244            })
245            .collect(),
246        Some(policy) => tools
247            .into_iter()
248            .filter_map(|tool| {
249                if !is_network_capable_tool(&tool) {
250                    return Some(tool);
251                }
252
253                if is_native_web_search_tool(&tool) {
254                    return apply_web_search_policy(skill, &tool, policy);
255                }
256
257                if is_gemini_native_network_tool(&tool) {
258                    info!(
259                        skill = skill.name(),
260                        tool = tool.function_name(),
261                        "Dropping Gemini native network tool because skill domain policy cannot be enforced safely"
262                    );
263                    return None;
264                }
265
266                info!(
267                    skill = skill.name(),
268                    tool = tool.function_name(),
269                    "Dropping network tool because skill policy cannot be enforced for function-style tools"
270                );
271                None
272            })
273            .collect(),
274    }
275}
276
277fn skill_additional_permissions(skill: &Skill) -> Option<AdditionalPermissions> {
278    let file_system = skill.manifest.permissions.as_ref()?.file_system.as_ref()?;
279    let fs_read = resolve_skill_permission_paths(skill.path.as_path(), &file_system.read);
280    let fs_write = resolve_skill_permission_paths(skill.path.as_path(), &file_system.write);
281    let permissions = AdditionalPermissions { fs_read, fs_write };
282    (!permissions.is_empty()).then_some(permissions)
283}
284
285fn resolve_skill_permission_paths(skill_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
286    let mut resolved = Vec::with_capacity(paths.len());
287    let mut seen = BTreeSet::new();
288
289    for path in paths {
290        if path.as_os_str().is_empty() {
291            continue;
292        }
293
294        let absolute = if path.is_absolute() {
295            path.clone()
296        } else {
297            skill_root.join(path)
298        };
299        let normalized = crate::utils::path::normalize_path(&absolute);
300        if seen.insert(normalized.clone()) {
301            resolved.push(normalized);
302        }
303    }
304
305    resolved
306}
307
308fn merge_permission_paths(existing: &[PathBuf], extra: &[PathBuf]) -> Vec<PathBuf> {
309    let mut merged = Vec::with_capacity(existing.len() + extra.len());
310    let mut seen = BTreeSet::new();
311
312    for path in existing.iter().chain(extra.iter()) {
313        if seen.insert(path.clone()) {
314            merged.push(path.clone());
315        }
316    }
317
318    merged
319}
320
321fn merge_additional_permissions(
322    existing: &AdditionalPermissions,
323    extra: &AdditionalPermissions,
324) -> AdditionalPermissions {
325    AdditionalPermissions {
326        fs_read: merge_permission_paths(&existing.fs_read, &extra.fs_read),
327        fs_write: merge_permission_paths(&existing.fs_write, &extra.fs_write),
328    }
329}
330
331fn merge_skill_command_permissions(skill: &Skill, tool_name: &str, tool_args: Value) -> Value {
332    if !tool_intent::is_command_run_tool_call(tool_name, &tool_args) {
333        return tool_args;
334    }
335
336    let Some(skill_permissions) = skill_additional_permissions(skill) else {
337        return tool_args;
338    };
339
340    let mut args = match tool_args {
341        Value::Object(args) => args,
342        other => return other,
343    };
344
345    let sandbox_permissions = match args.get("sandbox_permissions") {
346        Some(value) => match serde_json::from_value::<SandboxPermissions>(value.clone()) {
347            Ok(value) => value,
348            Err(_) => return Value::Object(args),
349        },
350        None => SandboxPermissions::UseDefault,
351    };
352
353    if matches!(
354        sandbox_permissions,
355        SandboxPermissions::RequireEscalated | SandboxPermissions::BypassSandbox
356    ) {
357        return Value::Object(args);
358    }
359
360    let existing_permissions = match args.get("additional_permissions") {
361        Some(value) => match serde_json::from_value::<AdditionalPermissions>(value.clone()) {
362            Ok(value) => value,
363            Err(_) => return Value::Object(args),
364        },
365        None => AdditionalPermissions::default(),
366    };
367
368    let merged_permissions =
369        merge_additional_permissions(&existing_permissions, &skill_permissions);
370    args.insert(
371        "sandbox_permissions".to_string(),
372        serde_json::to_value(SandboxPermissions::WithAdditionalPermissions)
373            .expect("sandbox permissions should serialize"),
374    );
375    args.insert(
376        "additional_permissions".to_string(),
377        serde_json::to_value(&merged_permissions).expect("additional permissions should serialize"),
378    );
379    debug!(
380        "Applied skill-scoped sandbox permissions for '{}' to tool '{}'",
381        skill.name(),
382        tool_name
383    );
384
385    Value::Object(args)
386}
387
388#[derive(Debug, Clone)]
389pub struct ForkSkillRuntimeConfig {
390    pub workspace: PathBuf,
391    pub model: String,
392    pub api_key: String,
393    pub openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
394    pub vt_cfg: Option<VTCodeConfig>,
395}
396
397#[async_trait]
398pub trait ForkSkillExecutor: Send + Sync {
399    async fn execute(&self, skill: &Skill, user_input: Value) -> Result<Value>;
400}
401
402#[derive(Clone)]
403pub struct ChildAgentSkillExecutor {
404    tool_registry: Arc<ToolRegistry>,
405    runtime: ForkSkillRuntimeConfig,
406}
407
408impl ChildAgentSkillExecutor {
409    pub fn new(tool_registry: Arc<ToolRegistry>, runtime: ForkSkillRuntimeConfig) -> Self {
410        Self {
411            tool_registry,
412            runtime,
413        }
414    }
415
416    async fn build_runner(&self, skill: &Skill, session_id: String) -> Result<AgentRunner> {
417        let model = self
418            .runtime
419            .model
420            .parse::<ModelId>()
421            .with_context(|| format!("invalid model for forked skill '{}'", skill.name()))?;
422
423        let mut runner = if let Some(vt_cfg) = self.runtime.vt_cfg.clone() {
424            AgentRunner::new_with_bootstrap(
425                fork_agent_type(skill),
426                model,
427                self.runtime.api_key.clone(),
428                self.runtime.workspace.clone(),
429                session_id,
430                RunnerSettings::default(),
431                None,
432                crate::core::threads::ThreadBootstrap::new(None),
433                Some(vt_cfg),
434                self.runtime.openai_chatgpt_auth.clone(),
435            )
436            .await?
437        } else {
438            AgentRunner::new_with_bootstrap(
439                fork_agent_type(skill),
440                model,
441                self.runtime.api_key.clone(),
442                self.runtime.workspace.clone(),
443                session_id,
444                RunnerSettings::default(),
445                None,
446                crate::core::threads::ThreadBootstrap::new(None),
447                None,
448                self.runtime.openai_chatgpt_auth.clone(),
449            )
450            .await?
451        };
452        runner.set_quiet(true);
453        Ok(runner)
454    }
455}
456
457fn skill_runs_in_fork(skill: &Skill) -> bool {
458    skill.manifest.context.as_deref() == Some("fork")
459}
460
461fn skill_tool_arg_transform(skill: Skill) -> Arc<SkillToolArgTransform> {
462    Arc::new(move |tool_name, tool_args| {
463        merge_skill_command_permissions(&skill, tool_name, tool_args)
464    })
465}
466
467fn fork_agent_type(skill: &Skill) -> AgentType {
468    match skill.manifest.agent.as_deref() {
469        Some("explore") => AgentType::Explore,
470        Some("plan") => AgentType::Plan,
471        Some("general") => AgentType::General,
472        _ => AgentType::General,
473    }
474}
475
476fn format_skill_user_input(user_input: &Value) -> String {
477    match user_input {
478        Value::String(text) => normalized_skill_user_input(text),
479        other => other.to_string(),
480    }
481}
482
483fn normalized_skill_user_input(user_input: &str) -> String {
484    if user_input.trim().is_empty() {
485        EMPTY_SKILL_INPUT_PROMPT.to_string()
486    } else {
487        user_input.to_string()
488    }
489}
490
491fn child_session_id(parent_session_id: &str, skill_name: &str) -> String {
492    format!(
493        "{}-skill-{}-{}",
494        crate::utils::session_debug::sanitize_debug_component(parent_session_id, "session"),
495        crate::utils::session_debug::sanitize_debug_component(skill_name, "skill"),
496        Utc::now().format("%Y%m%dT%H%M%SZ")
497    )
498}
499
500fn blocked_handoff_paths(events: &[crate::exec::events::ThreadEvent]) -> Vec<String> {
501    let mut paths = Vec::new();
502    for event in events {
503        let crate::exec::events::ThreadEvent::ItemCompleted(completed) = event else {
504            continue;
505        };
506        let crate::exec::events::ThreadItemDetails::Harness(harness) = &completed.item.details
507        else {
508            continue;
509        };
510        if harness.event == crate::exec::events::HarnessEventKind::BlockedHandoffWritten
511            && let Some(path) = harness.path.as_ref()
512            && !paths.iter().any(|existing| existing == path)
513        {
514            paths.push(path.clone());
515        }
516    }
517    paths
518}
519
520#[async_trait]
521impl ForkSkillExecutor for ChildAgentSkillExecutor {
522    async fn execute(&self, skill: &Skill, user_input: Value) -> Result<Value> {
523        let parent_session_id = self.tool_registry.harness_context_snapshot().session_id;
524        let session_id = child_session_id(&parent_session_id, skill.name());
525        let mut runner = self.build_runner(skill, session_id.clone()).await?;
526
527        let restricted_tools = filter_tools_for_skill(skill, runner.build_universal_tools().await?);
528        let allowed_tools = restricted_tools
529            .iter()
530            .map(|tool| tool.function_name().to_string())
531            .collect::<Vec<_>>();
532        runner.set_tool_definitions_override(restricted_tools);
533        runner.set_tool_arg_transform(skill_tool_arg_transform(skill.clone()));
534        runner.enable_full_auto(&allowed_tools).await;
535
536        let mut task = Task::new(
537            format!("fork-skill-{}", skill.name()),
538            format!("Skill {}", skill.name()),
539            format_skill_user_input(&user_input),
540        );
541        task.instructions = Some(skill.instructions.clone());
542
543        let results = runner.execute_task(&task, &[]).await?;
544        let mut artifact_paths = results.modified_files.clone();
545        let handoff_paths = blocked_handoff_paths(&results.thread_events);
546        for path in handoff_paths {
547            if !artifact_paths.iter().any(|existing| existing == &path) {
548                artifact_paths.push(path);
549            }
550        }
551
552        Ok(serde_json::json!({
553            "execution_context": "fork",
554            "status": results.outcome.code(),
555            "summary": if results.summary.trim().is_empty() {
556                results.outcome.description()
557            } else {
558                results.summary
559            },
560            "artifact_paths": artifact_paths,
561            "delegate_session_id": session_id,
562        }))
563    }
564}
565
566/// Execute a skill with LLM sub-call support (Phase 5)
567///
568/// Creates a sub-conversation where:
569/// 1. Skill instructions become the system prompt
570/// 2. User input becomes the first user message
571/// 3. All available tools are passed to the LLM
572/// 4. Tool calls are executed via the tool registry
573/// 5. Tool results are fed back to continue the conversation
574/// 6. Final response is returned
575///
576/// # Arguments
577/// * `skill` - The skill to execute
578/// * `user_input` - The user's input/request for the skill
579/// * `provider` - The LLM provider for sub-calls
580/// * `tool_registry` - The tool registry for executing nested tools
581/// * `available_tools` - Tools available to the skill
582/// * `model` - The model to use for skill execution
583pub async fn execute_skill_with_sub_llm(
584    skill: &Skill,
585    user_input: String,
586    provider: &(impl LLMProvider + ?Sized),
587    tool_registry: &mut ToolRegistry,
588    available_tools: Vec<ToolDefinition>,
589    model: String,
590) -> Result<String> {
591    debug!("Executing skill '{}' with LLM sub-call", skill.name());
592
593    // Apply network policy filtering
594    let available_tools = filter_tools_for_skill(skill, available_tools);
595    let tool_definitions = if available_tools.is_empty() {
596        None
597    } else {
598        Some(Arc::new(available_tools.clone()))
599    };
600    let normalized_user_input = normalized_skill_user_input(&user_input);
601
602    // Build conversation starting with user input
603    let mut messages = vec![Message::user(normalized_user_input)];
604
605    // Create LLM request with skill instructions as system prompt
606    let mut request = LLMRequest {
607        messages: messages.clone(),
608        system_prompt: Some(Arc::new(skill.instructions.clone())),
609        tools: tool_definitions.clone(),
610        model: model.clone(),
611        max_tokens: Some(4096),
612        ..Default::default()
613    };
614
615    // Loop: Make LLM request and handle tool calls
616    const BACKOFF_BASE_MS: u64 = 50; // initial back‑off delay
617    const MAX_RATE_LIMIT_WAIT_CYCLES: usize = 20;
618    const SKILL_RATE_LIMIT_KEY: &str = "skill_sub_llm";
619    let mut iterations = 0;
620    let mut backoff = BACKOFF_BASE_MS;
621    let mut wait_cycles = 0usize;
622    let mut loop_detector = LoopDetector::new();
623    let mut force_tool_free_synthesis = None;
624
625    loop {
626        let tool_free_synthesis_reason = force_tool_free_synthesis.take();
627        let is_tool_free_synthesis = tool_free_synthesis_reason.is_some();
628
629        if let Some(reason) = tool_free_synthesis_reason {
630            messages.push(Message::user(reason));
631            request.messages = messages.clone();
632            request.tools = None;
633        } else {
634            request.tools = tool_definitions.clone();
635        }
636
637        // Rate-limit tool-bearing iterations, but let the final no-tools recovery
638        // pass complete immediately so a stalled skill can still synthesize a result.
639        if !is_tool_free_synthesis {
640            if let Err(wait_hint) =
641                crate::tools::adaptive_rate_limiter::try_acquire_global(SKILL_RATE_LIMIT_KEY)
642            {
643                wait_cycles += 1;
644                if wait_cycles > MAX_RATE_LIMIT_WAIT_CYCLES {
645                    return Err(anyhow!(
646                        "Skill execution stayed rate-limited for too long ({} cycles)",
647                        MAX_RATE_LIMIT_WAIT_CYCLES
648                    ));
649                }
650
651                let delay = wait_hint
652                    .max(Duration::from_millis(backoff))
653                    .min(Duration::from_secs(2));
654                // If rate limited, wait a bit and retry without counting as an iteration
655                warn!(
656                    "Rate limit hit for skill execution – backing off {}ms",
657                    delay.as_millis()
658                );
659                tokio::time::sleep(delay).await;
660                backoff = (backoff * 2).min(2000); // cap back‑off at 2 s
661                continue;
662            }
663            wait_cycles = 0;
664            backoff = BACKOFF_BASE_MS;
665        }
666
667        if is_tool_free_synthesis {
668            info!(
669                "Skill '{}' entering tool-free final synthesis",
670                skill.name()
671            );
672        } else {
673            iterations += 1;
674            if iterations > MAX_SKILL_LLM_ITERATIONS {
675                let reason = skill_tool_free_synthesis_prompt(&format!(
676                    "Skill execution reached the maximum tool-call iterations ({}).",
677                    MAX_SKILL_LLM_ITERATIONS
678                ));
679                warn!(
680                    skill = skill.name(),
681                    iterations = iterations - 1,
682                    max_iterations = MAX_SKILL_LLM_ITERATIONS,
683                    "Skill hit max iterations; forcing tool-free final synthesis"
684                );
685                force_tool_free_synthesis = Some(reason);
686                continue;
687            }
688
689            info!("Skill LLM iteration {} for '{}'", iterations, skill.name());
690        }
691
692        // Make LLM request
693        let response = provider.generate(request.clone()).await?;
694
695        // Extract content - handle Option
696        let content = response.content.unwrap_or_default();
697
698        // Add assistant response to conversation
699        if let Some(tool_calls) = &response.tool_calls {
700            messages.push(Message::assistant_with_tools(
701                content.clone(),
702                tool_calls.clone(),
703            ));
704        } else {
705            messages.push(Message::assistant(content.clone()));
706        }
707
708        // Check if there are tool calls to handle
709        if let Some(tool_calls) = response.tool_calls {
710            if !tool_calls.is_empty() {
711                info!(
712                    "Skill '{}' made {} tool calls",
713                    skill.name(),
714                    tool_calls.len()
715                );
716                let mut force_tool_free_synthesis_reason = None;
717
718                // Execute each tool call
719                for tool_call in tool_calls {
720                    // Extract function name and arguments
721                    if let Some(tool_name) = tool_call.tool_name() {
722                        let tool_name = tool_name.to_string();
723
724                        debug!(
725                            "Executing tool '{}' for skill '{}'",
726                            tool_name,
727                            skill.name()
728                        );
729
730                        let tool_args = tool_call
731                            .execution_arguments()
732                            .unwrap_or_else(|_| serde_json::json!({}));
733                        let tool_args =
734                            merge_skill_command_permissions(skill, &tool_name, tool_args);
735
736                        if let Some(loop_warning) =
737                            loop_detector.record_call(&tool_name, &tool_args)
738                            && loop_detector.is_hard_limit_exceeded(&tool_name)
739                        {
740                            messages.push(Message::tool_response(
741                                tool_call.id.clone(),
742                                format!(
743                                    "{}\n\nTool execution was skipped to prevent a loop.",
744                                    loop_warning
745                                ),
746                            ));
747                            force_tool_free_synthesis_reason =
748                                Some(skill_tool_free_synthesis_prompt(&loop_warning));
749                            break;
750                        }
751
752                        // Execute tool via registry
753                        let tool_output = match tool_registry
754                            .execute_public_tool_ref(&tool_name, &tool_args)
755                            .await
756                        {
757                            Ok(result) => result,
758                            Err(e) => {
759                                warn!("Tool '{}' failed: {}", tool_name, e);
760                                ToolExecutionError::from_anyhow(
761                                    tool_name.to_string(),
762                                    &e,
763                                    0,
764                                    false,
765                                    false,
766                                    Some("skill_sub_llm"),
767                                )
768                                .to_json_value()
769                            }
770                        };
771                        let tool_error = ToolExecutionError::from_tool_output(&tool_output);
772                        let tool_result = tool_output.to_string();
773
774                        // Add tool result to conversation
775                        messages.push(Message::tool_response(tool_call.id.clone(), tool_result));
776                        if let Some(tool_error) = tool_error
777                            && should_force_tool_free_synthesis(&tool_error)
778                        {
779                            force_tool_free_synthesis_reason =
780                                Some(skill_tool_free_synthesis_prompt(&format!(
781                                    "The tool '{}' is not available for this skill. {}",
782                                    tool_name,
783                                    tool_error.user_message()
784                                )));
785                            break;
786                        }
787                    } else {
788                        warn!("Tool call has no function: {:?}", tool_call.call_type);
789                    }
790                }
791
792                // Update request for next iteration
793                request.messages = messages.clone();
794                if let Some(reason) = force_tool_free_synthesis_reason {
795                    force_tool_free_synthesis = Some(reason);
796                    continue;
797                }
798
799                // Continue loop to process tool results
800            } else {
801                // No tool calls, return the text response
802                return Ok(content);
803            }
804        } else {
805            // No tool calls, return the final response
806            return Ok(content);
807        }
808
809        // Check finish reason
810        match response.finish_reason {
811            FinishReason::Stop => {
812                // Normal termination
813                return Ok(content);
814            }
815            FinishReason::ToolCalls => {
816                // Continue to handle tool calls (already handled above)
817            }
818            FinishReason::Length => {
819                warn!("Skill '{}' hit token limit", skill.name());
820                return Ok(content);
821            }
822            FinishReason::ContentFilter => {
823                warn!(
824                    "Skill '{}' response filtered by content policy",
825                    skill.name()
826                );
827                return Ok(content);
828            }
829            FinishReason::Error(ref msg) => {
830                return Err(anyhow!("LLM error during skill execution: {}", msg));
831            }
832            FinishReason::Pause => {
833                // For skill execution, treatment is similar to ToolCalls: we continue the loop
834                // to process whatever triggered the pause (usually server-side tool use).
835            }
836            FinishReason::Refusal => {
837                return Err(anyhow!(
838                    "LLM refused to continue generating response due to policy violations"
839                ));
840            }
841        }
842    }
843}
844
845/// Adapter implementing Tool trait for a Skill
846#[derive(Clone)]
847pub struct SkillToolAdapter {
848    skill: Skill,
849    fork_executor: Option<Arc<dyn ForkSkillExecutor>>,
850}
851
852impl SkillToolAdapter {
853    /// Create a new skill tool adapter
854    pub fn new(skill: Skill) -> Self {
855        SkillToolAdapter {
856            skill,
857            fork_executor: None,
858        }
859    }
860
861    pub fn with_fork_executor(skill: Skill, fork_executor: Arc<dyn ForkSkillExecutor>) -> Self {
862        SkillToolAdapter {
863            skill,
864            fork_executor: Some(fork_executor),
865        }
866    }
867
868    /// Get reference to underlying skill
869    pub fn skill(&self) -> &Skill {
870        &self.skill
871    }
872
873    /// Get mutable reference to underlying skill
874    pub fn skill_mut(&mut self) -> &mut Skill {
875        &mut self.skill
876    }
877
878    /// Execute skill by invoking LLM with skill instructions as system prompt
879    async fn execute_skill_with_lm(&self, user_input: Value) -> Result<Value> {
880        debug!("Executing skill: {}", self.skill.name());
881
882        // Return structured result with skill instructions and context
883        // The agent harness will use this to invoke an LLM sub-call with:
884        // 1. Skill instructions as system prompt
885        // 2. User input in the message
886        // 3. Available tools for the skill to use
887        Ok(serde_json::json!({
888            "skill_name": self.skill.name(),
889            "status": "executing",
890            "description": self.skill.description(),
891            "instructions": self.skill.instructions,
892            "resources_available": self.skill.list_resources(),
893            "user_input": user_input,
894        }))
895    }
896
897    async fn execute_forked_skill(&self, user_input: Value) -> Result<Value> {
898        let executor = self
899            .fork_executor
900            .as_ref()
901            .ok_or_else(|| anyhow!("forked skill execution is not configured for this session"))?;
902        executor.execute(&self.skill, user_input).await
903    }
904}
905
906#[async_trait]
907impl crate::tools::traits::Tool for SkillToolAdapter {
908    async fn execute(&self, args: Value) -> Result<Value> {
909        info!("Skill tool executing: {}", self.skill.name());
910
911        let result = if skill_runs_in_fork(&self.skill) {
912            self.execute_forked_skill(args).await?
913        } else {
914            self.execute_skill_with_lm(args).await?
915        };
916
917        Ok(result)
918    }
919
920    fn name(&self) -> &str {
921        "traditional_skill_tool"
922    }
923
924    fn description(&self) -> &str {
925        "Traditional VT Code skill adapter"
926    }
927
928    fn validate_args(&self, args: &Value) -> Result<()> {
929        // Skills are flexible; accept any args
930        // The skill instructions will guide the LLM on what to do with them
931        if args.is_null() {
932            return Ok(());
933        }
934        Ok(())
935    }
936
937    fn parameter_schema(&self) -> Option<Value> {
938        // Skills are flexible, accept any input
939        Some(serde_json::json!({
940            "type": "object",
941            "description": "Flexible input for skill execution",
942            "additionalProperties": true,
943        }))
944    }
945
946    fn default_permission(&self) -> ToolPolicy {
947        // Skills require explicit permission due to potential resource usage
948        ToolPolicy::Prompt
949    }
950
951    fn allow_patterns(&self) -> Option<&'static [&'static str]> {
952        // Skills can define their own patterns, but by default none
953        None
954    }
955
956    fn deny_patterns(&self) -> Option<&'static [&'static str]> {
957        None
958    }
959
960    fn prompt_path(&self) -> Option<Cow<'static, str>> {
961        // Skills can bundle companion prompts
962        Some(Cow::Borrowed("skills/skill_instructions.md"))
963    }
964}
965
966/// Skill execution context passed to sub-LLM calls
967pub struct SkillExecutionContext {
968    pub skill_name: String,
969    pub instructions: String,
970    pub available_tools: Vec<String>,
971    pub user_input: Value,
972}
973
974impl SkillExecutionContext {
975    pub fn new(skill: &Skill, user_input: Value, available_tools: Vec<String>) -> Self {
976        SkillExecutionContext {
977            skill_name: skill.name().to_string(),
978            instructions: skill.instructions.clone(),
979            available_tools,
980            user_input,
981        }
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use crate::config::types::CapabilityLevel;
989    use crate::llm::provider::{
990        FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, ToolCall,
991    };
992    use crate::skills::types::{SkillFileSystemPermissions, SkillManifest, SkillPermissionProfile};
993    use crate::tools::registry::ToolRegistration;
994    use crate::tools::traits::Tool;
995    use serde_json::json;
996    use std::path::PathBuf;
997    use std::sync::Mutex;
998    use tempfile::tempdir;
999
1000    struct FakeForkExecutor;
1001
1002    struct EchoFirstUserProvider;
1003    struct UnknownToolThenFinalizeProvider {
1004        calls: Mutex<usize>,
1005    }
1006    struct RepeatToolThenFinalizeProvider {
1007        tool_name: &'static str,
1008        calls: Mutex<usize>,
1009    }
1010    struct MaxIterationsThenFinalizeProvider {
1011        tool_names: Vec<String>,
1012        calls: Mutex<usize>,
1013    }
1014    struct CountingSkillTool {
1015        calls: Arc<Mutex<usize>>,
1016    }
1017
1018    #[async_trait]
1019    impl LLMProvider for EchoFirstUserProvider {
1020        fn name(&self) -> &str {
1021            "echo-first-user"
1022        }
1023
1024        fn supported_models(&self) -> Vec<String> {
1025            vec!["gpt-5.1-codex".to_string()]
1026        }
1027
1028        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1029            Ok(())
1030        }
1031
1032        async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
1033            let first_message = request
1034                .messages
1035                .first()
1036                .map(|message| message.content.as_text().to_string())
1037                .unwrap_or_default();
1038
1039            Ok(LLMResponse {
1040                content: Some(first_message),
1041                model: request.model,
1042                finish_reason: FinishReason::Stop,
1043                ..Default::default()
1044            })
1045        }
1046    }
1047
1048    #[async_trait]
1049    impl LLMProvider for UnknownToolThenFinalizeProvider {
1050        fn name(&self) -> &str {
1051            "unknown-tool-then-finalize"
1052        }
1053
1054        fn supported_models(&self) -> Vec<String> {
1055            vec!["gpt-5.1-codex".to_string()]
1056        }
1057
1058        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1059            Ok(())
1060        }
1061
1062        async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
1063            let mut calls = self.calls.lock().expect("provider calls mutex");
1064            *calls += 1;
1065
1066            match *calls {
1067                1 => Ok(LLMResponse {
1068                    content: Some(String::new()),
1069                    model: request.model,
1070                    tool_calls: Some(vec![ToolCall::function(
1071                        "call_unknown_tool".to_string(),
1072                        "unified_diff".to_string(),
1073                        "{}".to_string(),
1074                    )]),
1075                    finish_reason: FinishReason::ToolCalls,
1076                    ..Default::default()
1077                }),
1078                2 => {
1079                    assert!(request.tools.is_none());
1080                    let prompt = request
1081                        .messages
1082                        .last()
1083                        .map(|message| message.content.as_text().to_string())
1084                        .unwrap_or_default();
1085                    assert!(prompt.contains("unified_diff"));
1086                    assert!(prompt.contains(SKILL_TOOL_FREE_SYNTHESIS_PROMPT));
1087
1088                    Ok(LLMResponse {
1089                        content: Some("finalized after unknown tool".to_string()),
1090                        model: request.model,
1091                        finish_reason: FinishReason::Stop,
1092                        ..Default::default()
1093                    })
1094                }
1095                _ => panic!("unexpected provider call count: {}", *calls),
1096            }
1097        }
1098    }
1099
1100    #[async_trait]
1101    impl LLMProvider for RepeatToolThenFinalizeProvider {
1102        fn name(&self) -> &str {
1103            "repeat-tool-then-finalize"
1104        }
1105
1106        fn supported_models(&self) -> Vec<String> {
1107            vec!["gpt-5.1-codex".to_string()]
1108        }
1109
1110        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1111            Ok(())
1112        }
1113
1114        async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
1115            let mut calls = self.calls.lock().expect("provider calls mutex");
1116            *calls += 1;
1117
1118            match *calls {
1119                1 | 2 => Ok(LLMResponse {
1120                    content: Some(String::new()),
1121                    model: request.model,
1122                    tool_calls: Some(vec![ToolCall::function(
1123                        format!("repeat_tool_call_{}", *calls),
1124                        self.tool_name.to_string(),
1125                        "{\"input\":\"same\"}".to_string(),
1126                    )]),
1127                    finish_reason: FinishReason::ToolCalls,
1128                    ..Default::default()
1129                }),
1130                3 => {
1131                    assert!(request.tools.is_none());
1132                    let prompt = request
1133                        .messages
1134                        .last()
1135                        .map(|message| message.content.as_text().to_string())
1136                        .unwrap_or_default();
1137                    assert!(prompt.contains("HARD STOP"));
1138                    assert!(prompt.contains(SKILL_TOOL_FREE_SYNTHESIS_PROMPT));
1139
1140                    Ok(LLMResponse {
1141                        content: Some("finalized after loop detection".to_string()),
1142                        model: request.model,
1143                        finish_reason: FinishReason::Stop,
1144                        ..Default::default()
1145                    })
1146                }
1147                _ => panic!("unexpected provider call count: {}", *calls),
1148            }
1149        }
1150    }
1151
1152    #[async_trait]
1153    impl LLMProvider for MaxIterationsThenFinalizeProvider {
1154        fn name(&self) -> &str {
1155            "max-iterations-then-finalize"
1156        }
1157
1158        fn supported_models(&self) -> Vec<String> {
1159            vec!["gpt-5.1-codex".to_string()]
1160        }
1161
1162        fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1163            Ok(())
1164        }
1165
1166        async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
1167            let mut calls = self.calls.lock().expect("provider calls mutex");
1168            *calls += 1;
1169
1170            if *calls <= MAX_SKILL_LLM_ITERATIONS {
1171                let tool_name = self.tool_names[*calls - 1].clone();
1172                return Ok(LLMResponse {
1173                    content: Some(String::new()),
1174                    model: request.model,
1175                    tool_calls: Some(vec![ToolCall::function(
1176                        format!("max_iterations_tool_call_{}", *calls),
1177                        tool_name,
1178                        format!("{{\"step\":{}}}", *calls),
1179                    )]),
1180                    finish_reason: FinishReason::ToolCalls,
1181                    ..Default::default()
1182                });
1183            }
1184
1185            assert_eq!(*calls, MAX_SKILL_LLM_ITERATIONS + 1);
1186            assert!(request.tools.is_none());
1187            let prompt = request
1188                .messages
1189                .last()
1190                .map(|message| message.content.as_text().to_string())
1191                .unwrap_or_default();
1192            assert!(prompt.contains("maximum tool-call iterations"));
1193            assert!(prompt.contains(&MAX_SKILL_LLM_ITERATIONS.to_string()));
1194            assert!(prompt.contains(SKILL_TOOL_FREE_SYNTHESIS_PROMPT));
1195
1196            Ok(LLMResponse {
1197                content: Some("finalized after max iterations".to_string()),
1198                model: request.model,
1199                finish_reason: FinishReason::Stop,
1200                ..Default::default()
1201            })
1202        }
1203    }
1204
1205    #[async_trait]
1206    impl ForkSkillExecutor for FakeForkExecutor {
1207        async fn execute(&self, skill: &Skill, user_input: Value) -> Result<Value> {
1208            Ok(serde_json::json!({
1209                "execution_context": "fork",
1210                "status": "success",
1211                "summary": format!("forked {}", skill.name()),
1212                "artifact_paths": [],
1213                "delegate_session_id": "child-session",
1214                "echo": user_input,
1215            }))
1216        }
1217    }
1218
1219    #[async_trait]
1220    impl Tool for CountingSkillTool {
1221        async fn execute(&self, args: Value) -> Result<Value> {
1222            let mut calls = self.calls.lock().expect("tool calls mutex");
1223            *calls += 1;
1224            Ok(json!({
1225                "success": true,
1226                "echo": args,
1227            }))
1228        }
1229
1230        fn name(&self) -> &str {
1231            "counting_skill_tool"
1232        }
1233
1234        fn description(&self) -> &str {
1235            "Counts skill tool invocations"
1236        }
1237    }
1238
1239    #[tokio::test]
1240    async fn test_skill_tool_adapter_exposes_underlying_skill_name() {
1241        let manifest = SkillManifest {
1242            name: "test-skill".to_string(),
1243            description: "Test skill".to_string(),
1244            vtcode_native: Some(true),
1245            ..Default::default()
1246        };
1247
1248        let skill = Skill::new(
1249            manifest,
1250            PathBuf::from("/tmp"),
1251            "# Instructions".to_string(),
1252        )
1253        .expect("failed to create skill");
1254
1255        let adapter = SkillToolAdapter::new(skill);
1256        assert_eq!(adapter.skill().name(), "test-skill");
1257    }
1258
1259    #[tokio::test]
1260    async fn test_skill_tool_adapter_execute() {
1261        let manifest = SkillManifest {
1262            name: "test-skill".to_string(),
1263            description: "Test skill".to_string(),
1264            vtcode_native: Some(true),
1265            ..Default::default()
1266        };
1267
1268        let skill = Skill::new(
1269            manifest,
1270            PathBuf::from("/tmp"),
1271            "# Test Instructions".to_string(),
1272        )
1273        .expect("failed to create skill");
1274
1275        let adapter = SkillToolAdapter::new(skill);
1276        let args = serde_json::json!({"test": "value"});
1277        let result = adapter.execute(args).await;
1278
1279        assert!(result.is_ok());
1280        let res = result.unwrap();
1281        assert_eq!(res["skill_name"], "test-skill");
1282        assert_eq!(res["status"], "executing");
1283    }
1284
1285    #[tokio::test]
1286    async fn test_fork_skill_adapter_uses_fork_executor() {
1287        let manifest = SkillManifest {
1288            name: "fork-skill".to_string(),
1289            description: "Forked skill".to_string(),
1290            context: Some("fork".to_string()),
1291            vtcode_native: Some(true),
1292            ..Default::default()
1293        };
1294
1295        let skill = Skill::new(
1296            manifest,
1297            PathBuf::from("/tmp"),
1298            "# Test Instructions".to_string(),
1299        )
1300        .expect("failed to create skill");
1301
1302        let adapter = SkillToolAdapter::with_fork_executor(skill, Arc::new(FakeForkExecutor));
1303        let args = serde_json::json!({"task": "value"});
1304        let result = adapter.execute(args.clone()).await.expect("fork execution");
1305
1306        assert_eq!(result["execution_context"], "fork");
1307        assert_eq!(result["delegate_session_id"], "child-session");
1308        assert_eq!(result["echo"], args);
1309    }
1310
1311    #[tokio::test]
1312    async fn blank_skill_input_uses_default_prompt_for_sub_llm() {
1313        let manifest = SkillManifest {
1314            name: "test-skill".to_string(),
1315            description: "Test skill".to_string(),
1316            vtcode_native: Some(true),
1317            ..Default::default()
1318        };
1319        let skill = Skill::new(
1320            manifest,
1321            PathBuf::from("/tmp"),
1322            "# Test Instructions".to_string(),
1323        )
1324        .expect("failed to create skill");
1325        let workspace = tempdir().expect("temp workspace");
1326        let mut registry = ToolRegistry::new(workspace.path().to_path_buf()).await;
1327
1328        let result = execute_skill_with_sub_llm(
1329            &skill,
1330            String::new(),
1331            &EchoFirstUserProvider,
1332            &mut registry,
1333            Vec::new(),
1334            "gpt-5.1-codex".to_string(),
1335        )
1336        .await
1337        .expect("blank input should be normalized");
1338
1339        assert_eq!(result, EMPTY_SKILL_INPUT_PROMPT);
1340    }
1341
1342    #[tokio::test]
1343    async fn non_empty_skill_input_is_preserved_for_sub_llm() {
1344        let manifest = SkillManifest {
1345            name: "test-skill".to_string(),
1346            description: "Test skill".to_string(),
1347            vtcode_native: Some(true),
1348            ..Default::default()
1349        };
1350        let skill = Skill::new(
1351            manifest,
1352            PathBuf::from("/tmp"),
1353            "# Test Instructions".to_string(),
1354        )
1355        .expect("failed to create skill");
1356        let workspace = tempdir().expect("temp workspace");
1357        let mut registry = ToolRegistry::new(workspace.path().to_path_buf()).await;
1358
1359        let result = execute_skill_with_sub_llm(
1360            &skill,
1361            "security".to_string(),
1362            &EchoFirstUserProvider,
1363            &mut registry,
1364            Vec::new(),
1365            "gpt-5.1-codex".to_string(),
1366        )
1367        .await
1368        .expect("non-empty input should be preserved");
1369
1370        assert_eq!(result, "security");
1371    }
1372
1373    #[tokio::test]
1374    async fn skill_executor_forces_final_synthesis_after_unknown_tool() {
1375        let manifest = SkillManifest {
1376            name: "test-skill".to_string(),
1377            description: "Test skill".to_string(),
1378            vtcode_native: Some(true),
1379            ..Default::default()
1380        };
1381        let skill = Skill::new(
1382            manifest,
1383            PathBuf::from("/tmp"),
1384            "# Test Instructions".to_string(),
1385        )
1386        .expect("failed to create skill");
1387        let workspace = tempdir().expect("temp workspace");
1388        let mut registry = ToolRegistry::new(workspace.path().to_path_buf()).await;
1389        registry.allow_all_tools().await.expect("allow tools");
1390        let provider = UnknownToolThenFinalizeProvider {
1391            calls: Mutex::new(0),
1392        };
1393
1394        let result = execute_skill_with_sub_llm(
1395            &skill,
1396            "review".to_string(),
1397            &provider,
1398            &mut registry,
1399            vec![ToolDefinition::function(
1400                "read_file".to_string(),
1401                "Read".to_string(),
1402                json!({"type": "object"}),
1403            )],
1404            "gpt-5.1-codex".to_string(),
1405        )
1406        .await
1407        .expect("unknown tool should trigger final synthesis");
1408
1409        assert_eq!(result, "finalized after unknown tool");
1410    }
1411
1412    #[tokio::test]
1413    async fn skill_executor_skips_repeated_tool_call_and_finalizes() {
1414        let manifest = SkillManifest {
1415            name: "test-skill".to_string(),
1416            description: "Test skill".to_string(),
1417            vtcode_native: Some(true),
1418            ..Default::default()
1419        };
1420        let skill = Skill::new(
1421            manifest,
1422            PathBuf::from("/tmp"),
1423            "# Test Instructions".to_string(),
1424        )
1425        .expect("failed to create skill");
1426        let workspace = tempdir().expect("temp workspace");
1427        let mut registry = ToolRegistry::new(workspace.path().to_path_buf()).await;
1428        let tool_name = "skill_loop_test_tool";
1429        let tool_calls = Arc::new(Mutex::new(0usize));
1430        registry
1431            .register_tool(ToolRegistration::from_tool_instance(
1432                tool_name,
1433                CapabilityLevel::CodeSearch,
1434                CountingSkillTool {
1435                    calls: Arc::clone(&tool_calls),
1436                },
1437            ))
1438            .await
1439            .expect("register tool");
1440        registry.allow_all_tools().await.expect("allow tools");
1441        let provider = RepeatToolThenFinalizeProvider {
1442            tool_name,
1443            calls: Mutex::new(0),
1444        };
1445
1446        let result = execute_skill_with_sub_llm(
1447            &skill,
1448            "review".to_string(),
1449            &provider,
1450            &mut registry,
1451            vec![ToolDefinition::function(
1452                tool_name.to_string(),
1453                "Loop test tool".to_string(),
1454                json!({"type": "object"}),
1455            )],
1456            "gpt-5.1-codex".to_string(),
1457        )
1458        .await
1459        .expect("looping tool calls should force a final synthesis");
1460
1461        assert_eq!(result, "finalized after loop detection");
1462        assert_eq!(*tool_calls.lock().expect("tool calls mutex"), 1);
1463    }
1464
1465    #[tokio::test]
1466    async fn skill_executor_forces_final_synthesis_after_max_iterations() {
1467        let manifest = SkillManifest {
1468            name: "test-skill".to_string(),
1469            description: "Test skill".to_string(),
1470            vtcode_native: Some(true),
1471            ..Default::default()
1472        };
1473        let skill = Skill::new(
1474            manifest,
1475            PathBuf::from("/tmp"),
1476            "# Test Instructions".to_string(),
1477        )
1478        .expect("failed to create skill");
1479        let workspace = tempdir().expect("temp workspace");
1480        let mut registry = ToolRegistry::new(workspace.path().to_path_buf()).await;
1481        let tool_calls = Arc::new(Mutex::new(0usize));
1482        let mut available_tools = Vec::with_capacity(MAX_SKILL_LLM_ITERATIONS);
1483        let mut tool_names = Vec::with_capacity(MAX_SKILL_LLM_ITERATIONS);
1484
1485        for index in 0..MAX_SKILL_LLM_ITERATIONS {
1486            let tool_name = format!("skill_iteration_test_tool_{index}");
1487            registry
1488                .register_tool(ToolRegistration::from_tool_instance(
1489                    tool_name.as_str(),
1490                    CapabilityLevel::CodeSearch,
1491                    CountingSkillTool {
1492                        calls: Arc::clone(&tool_calls),
1493                    },
1494                ))
1495                .await
1496                .unwrap_or_else(|error| panic!("register tool {tool_name}: {error}"));
1497            available_tools.push(ToolDefinition::function(
1498                tool_name.clone(),
1499                format!("Iteration tool {index}"),
1500                json!({"type": "object"}),
1501            ));
1502            tool_names.push(tool_name);
1503        }
1504
1505        registry.allow_all_tools().await.expect("allow tools");
1506        let provider = MaxIterationsThenFinalizeProvider {
1507            tool_names,
1508            calls: Mutex::new(0),
1509        };
1510
1511        let result = execute_skill_with_sub_llm(
1512            &skill,
1513            "analyze".to_string(),
1514            &provider,
1515            &mut registry,
1516            available_tools,
1517            "gpt-5.1-codex".to_string(),
1518        )
1519        .await
1520        .expect("max-iteration recovery should force a final synthesis");
1521
1522        assert_eq!(result, "finalized after max iterations");
1523        assert_eq!(
1524            *tool_calls.lock().expect("tool calls mutex"),
1525            MAX_SKILL_LLM_ITERATIONS
1526        );
1527    }
1528
1529    #[test]
1530    fn test_filter_tools_no_network_policy() {
1531        let manifest = SkillManifest {
1532            name: "test-skill".to_string(),
1533            description: "Test".to_string(),
1534            network_policy: None,
1535            vtcode_native: Some(true),
1536            ..Default::default()
1537        };
1538        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1539            .expect("failed to create skill");
1540
1541        let tools = vec![
1542            ToolDefinition::function(
1543                "read_file".to_string(),
1544                "Read".to_string(),
1545                serde_json::json!({}),
1546            ),
1547            ToolDefinition::web_search(serde_json::json!({})),
1548            ToolDefinition::function(
1549                "web_search".to_string(),
1550                "Search".to_string(),
1551                serde_json::json!({}),
1552            ),
1553        ];
1554        let filtered = filter_tools_for_skill(&skill, tools);
1555        assert_eq!(filtered.len(), 1);
1556        assert_eq!(filtered[0].function.as_ref().unwrap().name, "read_file");
1557    }
1558
1559    #[test]
1560    fn test_filter_tools_with_network_policy_updates_native_web_search() {
1561        let manifest = SkillManifest {
1562            name: "test-skill".to_string(),
1563            description: "Test".to_string(),
1564            network_policy: Some(
1565                SkillNetworkPolicy {
1566                    allowed_domains: vec!["api.example.com".to_string()],
1567                    denied_domains: vec!["blocked.example.com".to_string()],
1568                }
1569                .into(),
1570            ),
1571            vtcode_native: Some(true),
1572            ..Default::default()
1573        };
1574        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1575            .expect("failed to create skill");
1576
1577        let tools = vec![ToolDefinition::web_search(serde_json::json!({
1578            "user_location": "US"
1579        }))];
1580        let filtered = filter_tools_for_skill(&skill, tools);
1581        assert_eq!(filtered.len(), 1);
1582        assert_eq!(filtered[0].tool_type, "web_search");
1583        assert_eq!(
1584            filtered[0].web_search.as_ref(),
1585            Some(&serde_json::json!({
1586                "user_location": "US",
1587                "allowed_domains": ["api.example.com"],
1588                "blocked_domains": ["blocked.example.com"]
1589            }))
1590        );
1591    }
1592
1593    #[test]
1594    fn test_filter_tools_no_network_policy_removes_gemini_native_network_tools() {
1595        let manifest = SkillManifest {
1596            name: "test-skill".to_string(),
1597            description: "Test".to_string(),
1598            network_policy: None,
1599            vtcode_native: Some(true),
1600            ..Default::default()
1601        };
1602        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1603            .expect("failed to create skill");
1604
1605        let tools = vec![
1606            ToolDefinition::google_maps(serde_json::json!({})),
1607            ToolDefinition::url_context(serde_json::json!({})),
1608            ToolDefinition::function(
1609                "read_file".to_string(),
1610                "Read".to_string(),
1611                serde_json::json!({}),
1612            ),
1613        ];
1614
1615        let filtered = filter_tools_for_skill(&skill, tools);
1616        assert_eq!(filtered.len(), 1);
1617        assert_eq!(filtered[0].function_name(), "read_file");
1618    }
1619
1620    #[test]
1621    fn test_filter_tools_with_network_policy_drops_gemini_native_network_tools() {
1622        let manifest = SkillManifest {
1623            name: "test-skill".to_string(),
1624            description: "Test".to_string(),
1625            network_policy: Some(
1626                SkillNetworkPolicy {
1627                    allowed_domains: vec!["example.com".to_string()],
1628                    denied_domains: vec![],
1629                }
1630                .into(),
1631            ),
1632            vtcode_native: Some(true),
1633            ..Default::default()
1634        };
1635        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1636            .expect("failed to create skill");
1637
1638        let filtered = filter_tools_for_skill(
1639            &skill,
1640            vec![
1641                ToolDefinition::google_maps(serde_json::json!({})),
1642                ToolDefinition::url_context(serde_json::json!({})),
1643            ],
1644        );
1645
1646        assert!(filtered.is_empty());
1647    }
1648
1649    #[test]
1650    fn test_filter_tools_drops_function_style_network_tools_when_policy_is_present() {
1651        let manifest = SkillManifest {
1652            name: "test-skill".to_string(),
1653            description: "Test".to_string(),
1654            network_policy: Some(
1655                SkillNetworkPolicy {
1656                    allowed_domains: vec!["api.example.com".to_string()],
1657                    denied_domains: vec![],
1658                }
1659                .into(),
1660            ),
1661            vtcode_native: Some(true),
1662            ..Default::default()
1663        };
1664        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1665            .expect("failed to create skill");
1666
1667        let tools = vec![
1668            ToolDefinition::function(
1669                "read_web_page".to_string(),
1670                "Read web page".to_string(),
1671                serde_json::json!({}),
1672            ),
1673            ToolDefinition::function(
1674                "read_file".to_string(),
1675                "Read".to_string(),
1676                serde_json::json!({}),
1677            ),
1678        ];
1679        let filtered = filter_tools_for_skill(&skill, tools);
1680
1681        assert_eq!(filtered.len(), 1);
1682        assert_eq!(filtered[0].function_name(), "read_file");
1683    }
1684
1685    #[test]
1686    fn test_filter_tools_fails_closed_for_unrepresentable_web_search_policy() {
1687        let manifest = SkillManifest {
1688            name: "test-skill".to_string(),
1689            description: "Test".to_string(),
1690            network_policy: Some(
1691                SkillNetworkPolicy {
1692                    allowed_domains: vec!["docs.rs".to_string()],
1693                    denied_domains: vec!["example.com".to_string()],
1694                }
1695                .into(),
1696            ),
1697            vtcode_native: Some(true),
1698            ..Default::default()
1699        };
1700        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "instructions".to_string())
1701            .expect("failed to create skill");
1702
1703        let mut anthropic_web_search = ToolDefinition::web_search(serde_json::json!({}));
1704        anthropic_web_search.tool_type = "web_search_20250305".to_string();
1705
1706        let filtered = filter_tools_for_skill(&skill, vec![anthropic_web_search]);
1707
1708        assert!(filtered.is_empty());
1709    }
1710
1711    #[test]
1712    fn test_skill_execution_context() {
1713        let manifest = SkillManifest {
1714            name: "test-skill".to_string(),
1715            description: "Test skill".to_string(),
1716            vtcode_native: Some(true),
1717            ..Default::default()
1718        };
1719
1720        let skill = Skill::new(manifest, PathBuf::from("/tmp"), "Instructions".to_string())
1721            .expect("failed to create skill");
1722
1723        let tools = vec!["file_ops".to_string(), "shell".to_string()];
1724        let input = serde_json::json!({"test": "input"});
1725
1726        let ctx = SkillExecutionContext::new(&skill, input, tools);
1727        assert_eq!(ctx.skill_name, "test-skill");
1728        assert_eq!(ctx.available_tools.len(), 2);
1729    }
1730
1731    fn test_skill_with_permissions(permission_profile: Option<SkillPermissionProfile>) -> Skill {
1732        let manifest = SkillManifest {
1733            name: "test-skill".to_string(),
1734            description: "Test skill".to_string(),
1735            permissions: permission_profile.map(Into::into),
1736            vtcode_native: Some(true),
1737            ..Default::default()
1738        };
1739
1740        Skill::new(
1741            manifest,
1742            PathBuf::from("/tmp/test-skill"),
1743            "Instructions".to_string(),
1744        )
1745        .expect("failed to create skill")
1746    }
1747
1748    #[test]
1749    fn skill_command_permissions_inject_additional_permissions() {
1750        let skill = test_skill_with_permissions(Some(SkillPermissionProfile {
1751            file_system: Some(
1752                SkillFileSystemPermissions {
1753                    read: vec![PathBuf::from("references")],
1754                    write: vec![PathBuf::from("outputs")],
1755                }
1756                .into(),
1757            ),
1758        }));
1759
1760        let merged =
1761            merge_skill_command_permissions(&skill, "shell", serde_json::json!({"command": "pwd"}));
1762
1763        assert_eq!(
1764            merged["sandbox_permissions"],
1765            serde_json::json!("with_additional_permissions")
1766        );
1767        assert_eq!(
1768            merged["additional_permissions"]["fs_read"],
1769            serde_json::json!(["/tmp/test-skill/references"])
1770        );
1771        assert_eq!(
1772            merged["additional_permissions"]["fs_write"],
1773            serde_json::json!(["/tmp/test-skill/outputs"])
1774        );
1775    }
1776
1777    #[test]
1778    fn skill_command_permissions_merge_existing_permissions() {
1779        let skill = test_skill_with_permissions(Some(SkillPermissionProfile {
1780            file_system: Some(
1781                SkillFileSystemPermissions {
1782                    read: vec![PathBuf::from("references")],
1783                    write: vec![PathBuf::from("outputs")],
1784                }
1785                .into(),
1786            ),
1787        }));
1788
1789        let merged = merge_skill_command_permissions(
1790            &skill,
1791            "shell",
1792            serde_json::json!({
1793                "command": "pwd",
1794                "sandbox_permissions": "with_additional_permissions",
1795                "additional_permissions": {
1796                    "fs_read": ["/tmp/existing-read"],
1797                    "fs_write": ["/tmp/existing-write"]
1798                }
1799            }),
1800        );
1801
1802        assert_eq!(
1803            merged["additional_permissions"]["fs_read"],
1804            serde_json::json!(["/tmp/existing-read", "/tmp/test-skill/references"])
1805        );
1806        assert_eq!(
1807            merged["additional_permissions"]["fs_write"],
1808            serde_json::json!(["/tmp/existing-write", "/tmp/test-skill/outputs"])
1809        );
1810    }
1811
1812    #[test]
1813    fn skill_command_permissions_ignore_require_escalated() {
1814        let skill = test_skill_with_permissions(Some(SkillPermissionProfile {
1815            file_system: Some(
1816                SkillFileSystemPermissions {
1817                    read: Vec::new(),
1818                    write: vec![PathBuf::from("outputs")],
1819                }
1820                .into(),
1821            ),
1822        }));
1823        let original = serde_json::json!({
1824            "command": "pwd",
1825            "sandbox_permissions": "require_escalated",
1826            "justification": "Do you want to run this command without sandbox restrictions?"
1827        });
1828
1829        let merged = merge_skill_command_permissions(&skill, "shell", original.clone());
1830
1831        assert_eq!(merged, original);
1832    }
1833
1834    #[test]
1835    fn skill_command_permissions_ignore_empty_skill_permissions() {
1836        let skill = test_skill_with_permissions(None);
1837        let original = serde_json::json!({"command": "pwd"});
1838
1839        let merged = merge_skill_command_permissions(&skill, "shell", original.clone());
1840
1841        assert_eq!(merged, original);
1842    }
1843}