1use 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
55const 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
223pub 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
566pub 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 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 let mut messages = vec![Message::user(normalized_user_input)];
604
605 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 const BACKOFF_BASE_MS: u64 = 50; 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 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 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); 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 let response = provider.generate(request.clone()).await?;
694
695 let content = response.content.unwrap_or_default();
697
698 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 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 for tool_call in tool_calls {
720 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 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 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 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 } else {
801 return Ok(content);
803 }
804 } else {
805 return Ok(content);
807 }
808
809 match response.finish_reason {
811 FinishReason::Stop => {
812 return Ok(content);
814 }
815 FinishReason::ToolCalls => {
816 }
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 }
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#[derive(Clone)]
847pub struct SkillToolAdapter {
848 skill: Skill,
849 fork_executor: Option<Arc<dyn ForkSkillExecutor>>,
850}
851
852impl SkillToolAdapter {
853 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 pub fn skill(&self) -> &Skill {
870 &self.skill
871 }
872
873 pub fn skill_mut(&mut self) -> &mut Skill {
875 &mut self.skill
876 }
877
878 async fn execute_skill_with_lm(&self, user_input: Value) -> Result<Value> {
880 debug!("Executing skill: {}", self.skill.name());
881
882 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 if args.is_null() {
932 return Ok(());
933 }
934 Ok(())
935 }
936
937 fn parameter_schema(&self) -> Option<Value> {
938 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 ToolPolicy::Prompt
949 }
950
951 fn allow_patterns(&self) -> Option<&'static [&'static str]> {
952 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 Some(Cow::Borrowed("skills/skill_instructions.md"))
963 }
964}
965
966pub 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}