1use anyhow::Result;
2use std::collections::BTreeMap;
3use std::path::Path;
4use std::path::PathBuf;
5use vtcode_config::core::permissions::AgentPermissionsConfig;
6use vtcode_config::core::tools::ToolPolicy;
7use vtcode_config::{
8 HooksConfig, McpProviderConfig, SubagentMcpServer, SubagentMemoryScope, SubagentSource, SubagentSpec,
9};
10
11use super::constants::{
12 CHILD_BLOCKED_BACKGROUND_TOOL_NAMES, NON_MUTATING_TOOL_PREFIXES, SUBAGENT_MIN_BACKGROUND_MAX_TURNS,
13 SUBAGENT_MIN_MAX_TURNS, SUBAGENT_TOOL_NAMES,
14};
15use crate::config::VTCodeConfig;
16use crate::config::constants::tools;
17use crate::config::models::ModelId;
18use crate::config::types::{ReasoningEffortLevel, SystemPromptMode, ToolDocumentationMode};
19use crate::core::loop_detector::{SUBAGENT_MAX_TOTAL_READONLY_CALLS, SUBAGENT_NAVIGATION_HARD_STOP_STREAK};
20use crate::core::threads::build_thread_archive_metadata;
21use crate::llm::provider::ToolDefinition;
22use crate::tools::mcp::MCP_QUALIFIED_TOOL_PREFIX;
23use crate::utils::session_archive::{SessionArchiveMetadata, SessionForkMode};
24
25#[derive(Debug, Clone)]
26pub struct ResolvedAgentRuntimeView {
27 pub canonical_name: String,
28 pub display_name: String,
29 pub description: String,
30 pub color: Option<String>,
31 pub aliases: Vec<String>,
32 pub instructions: String,
33 pub tools: Option<Vec<String>>,
34 pub disallowed_tools: Vec<String>,
35 pub permissions: AgentPermissionsConfig,
36 pub model: Option<String>,
37 pub reasoning_effort: Option<ReasoningEffortLevel>,
38 pub hooks: Option<HooksConfig>,
39 pub mcp_servers: Vec<SubagentMcpServer>,
40 pub skills: Vec<String>,
41 pub memory: Option<SubagentMemoryScope>,
42 pub read_only: bool,
43 pub source: SubagentSource,
44 pub file_path: Option<PathBuf>,
45 pub tool_policy_overrides: BTreeMap<String, ToolPolicy>,
46}
47
48impl ResolvedAgentRuntimeView {
49 #[must_use]
50 pub fn from_spec(spec: &SubagentSpec) -> Self {
51 Self {
52 canonical_name: spec.name.clone(),
53 display_name: spec.name.clone(),
54 description: spec.description.clone(),
55 color: spec.color.clone(),
56 aliases: spec.aliases.clone(),
57 instructions: spec.prompt.clone(),
58 tools: spec.tools.clone(),
59 disallowed_tools: spec.disallowed_tools.clone(),
60 permissions: spec.permissions.clone(),
61 model: spec.model.clone(),
62 reasoning_effort: spec.reasoning_effort,
63 hooks: spec.hooks.clone(),
64 mcp_servers: spec.mcp_servers.clone(),
65 skills: spec.skills.clone(),
66 memory: spec.memory,
67 read_only: spec.is_read_only(),
68 source: spec.source.clone(),
69 file_path: spec.file_path.clone(),
70 tool_policy_overrides: spec.tool_policy_overrides.clone(),
71 }
72 }
73}
74
75pub fn build_child_config(
78 parent: &VTCodeConfig,
79 spec: &SubagentSpec,
80 model: &str,
81 max_turns: Option<usize>,
82 allow_nested_delegation: bool,
83) -> VTCodeConfig {
84 build_child_config_from_runtime(
85 parent,
86 &ResolvedAgentRuntimeView::from_spec(spec),
87 model,
88 max_turns,
89 allow_nested_delegation,
90 )
91}
92
93fn build_child_config_from_runtime(
94 parent: &VTCodeConfig,
95 runtime: &ResolvedAgentRuntimeView,
96 model: &str,
97 max_turns: Option<usize>,
98 allow_nested_delegation: bool,
99) -> VTCodeConfig {
100 let mut child = parent.clone();
101 child.default_primary_agent = "build".to_owned();
103 child.agent.default_model = model.to_string();
104 child.runtime_agent_permissions = Some(runtime.permissions.clone());
105 apply_subagent_lightweight_profile(&mut child);
111 normalize_child_max_turns_config(&mut child, max_turns);
112
113 child.permissions.allow = resolve_child_allowed_tools(parent, runtime, allow_nested_delegation);
114 child.permissions.deny = resolve_child_denied_tools(parent, runtime, allow_nested_delegation);
115 merge_child_hooks(&mut child, runtime.hooks.as_ref());
116 child.mcp.providers = resolve_child_mcp_providers(parent, runtime);
121 child
122}
123
124fn apply_subagent_lightweight_profile(child: &mut VTCodeConfig) {
128 child.agent.system_prompt_mode = SystemPromptMode::Minimal;
129 child.agent.tool_documentation_mode = ToolDocumentationMode::Minimal;
130}
131
132fn blocked_child_tool_names(allow_nested_delegation: bool) -> &'static [&'static str] {
139 if allow_nested_delegation {
140 CHILD_BLOCKED_BACKGROUND_TOOL_NAMES
141 } else {
142 SUBAGENT_TOOL_NAMES
143 }
144}
145
146fn resolve_child_allowed_tools(
152 parent: &VTCodeConfig,
153 runtime: &ResolvedAgentRuntimeView,
154 allow_nested_delegation: bool,
155) -> Vec<String> {
156 let blocked = blocked_child_tool_names(allow_nested_delegation);
157 let allowed_tools = runtime.tools.clone().unwrap_or_default();
158 if allowed_tools.is_empty() {
159 return parent.permissions.allow.clone();
160 }
161 let filtered: Vec<String> = allowed_tools
162 .into_iter()
163 .filter(|tool| !blocked.iter().any(|blocked| blocked == tool))
164 .collect();
165 intersect_allowed_tools(&parent.permissions.allow, &filtered)
166}
167
168fn resolve_child_denied_tools(
174 parent: &VTCodeConfig,
175 runtime: &ResolvedAgentRuntimeView,
176 allow_nested_delegation: bool,
177) -> Vec<String> {
178 let blocked = blocked_child_tool_names(allow_nested_delegation);
179 let mut denied = parent.permissions.deny.clone();
180 denied.extend(runtime.disallowed_tools.clone());
181 for tool in blocked {
182 if !denied.iter().any(|entry| entry == tool) {
183 denied.push((*tool).to_string());
184 }
185 }
186 denied
187}
188
189fn resolve_child_mcp_providers(parent: &VTCodeConfig, runtime: &ResolvedAgentRuntimeView) -> Vec<McpProviderConfig> {
193 let mut providers = Vec::new();
194 merge_child_mcp_servers(&mut providers, &parent.mcp.providers, runtime.mcp_servers.as_slice());
195 providers
196}
197
198fn normalize_child_max_turns_config(child: &mut VTCodeConfig, max_turns: Option<usize>) {
199 if let Some(max_turns) = normalize_child_max_turns(max_turns) {
200 child.automation.full_auto.max_turns = max_turns;
201 }
202}
203
204pub fn normalize_child_max_turns(max_turns: Option<usize>) -> Option<usize> {
205 max_turns.map(|value| value.max(SUBAGENT_MIN_MAX_TURNS))
206}
207
208pub fn normalize_background_child_max_turns(max_turns: Option<usize>, background: bool) -> Option<usize> {
209 let normalized = normalize_child_max_turns(max_turns);
210 if background {
211 normalized.map(|value| value.max(SUBAGENT_MIN_BACKGROUND_MAX_TURNS))
212 } else {
213 normalized
214 }
215}
216
217#[expect(
218 clippy::too_many_arguments,
219 reason = "All parameters are required to resolve a child runtime config (parent identity, model overrides, and nesting policy)."
220)]
221pub fn prepare_child_runtime_config(
222 parent: &VTCodeConfig,
223 spec: &SubagentSpec,
224 parent_model: &str,
225 parent_provider: &str,
226 parent_reasoning_effort: ReasoningEffortLevel,
227 max_turns: Option<usize>,
228 model_override: Option<&str>,
229 reasoning_override: Option<&str>,
230 allow_nested_delegation: bool,
231 resolve_model: impl FnOnce(&VTCodeConfig, &str, &str, Option<&str>, Option<&str>, &str) -> Result<ModelId>,
232) -> Result<(ModelId, ReasoningEffortLevel, VTCodeConfig)> {
233 let runtime = ResolvedAgentRuntimeView::from_spec(spec);
234 let resolved_model = resolve_model(
235 parent,
236 parent_model,
237 parent_provider,
238 model_override,
239 runtime.model.as_deref(),
240 runtime.canonical_name.as_str(),
241 )?;
242 let mut child_cfg =
243 build_child_config_from_runtime(parent, &runtime, &resolved_model.as_str(), max_turns, allow_nested_delegation);
244 let child_reasoning_effort = reasoning_override
245 .and_then(ReasoningEffortLevel::parse)
246 .or(runtime.reasoning_effort)
247 .unwrap_or(parent_reasoning_effort);
248 child_cfg.agent.default_model = resolved_model.to_string();
249 child_cfg.agent.reasoning_effort = child_reasoning_effort;
250 Ok((resolved_model, child_reasoning_effort, child_cfg))
251}
252
253fn intersect_allowed_tools(parent_allowed: &[String], spec_allowed: &[String]) -> Vec<String> {
254 if parent_allowed.is_empty() {
255 return spec_allowed.to_vec();
256 }
257
258 parent_allowed
259 .iter()
260 .filter(|rule| parent_rule_matches_spec_tools(rule, spec_allowed))
261 .cloned()
262 .collect()
263}
264
265fn parent_rule_matches_spec_tools(rule: &str, spec_allowed: &[String]) -> bool {
266 let rule = rule.trim();
267 if rule.is_empty() {
268 return false;
269 }
270
271 let prefix = rule.split_once('(').map_or(rule, |(prefix, _)| prefix).trim();
272 match prefix.to_ascii_lowercase().as_str() {
273 "read" => spec_allowed.iter().any(|tool| tool_supports_read_permission(tool)),
274 "edit" => spec_allowed.iter().any(|tool| tool_supports_edit_permission(tool)),
275 "write" => spec_allowed.iter().any(|tool| tool_supports_write_permission(tool)),
276 "bash" => spec_allowed.iter().any(|tool| tool_supports_bash_permission(tool)),
277 "webfetch" => spec_allowed.iter().any(|tool| tool_supports_web_fetch_permission(tool)),
278 _ if rule.starts_with(MCP_QUALIFIED_TOOL_PREFIX) => {
279 spec_allowed.iter().any(|tool| canonical_mcp_rule_matches_tool(rule, tool))
280 }
281 _ if rule.contains(['(', ')']) => false,
282 _ => spec_allowed.iter().any(|tool| tool.trim().eq_ignore_ascii_case(rule)),
283 }
284}
285
286#[must_use]
287fn tool_supports_read_permission(tool: &str) -> bool {
288 matches!(
289 tool.trim(),
290 tools::CODE_SEARCH
291 | tools::EXEC_COMMAND
292 | tools::READ_FILE
293 | tools::GREP_FILE
294 | tools::LIST_FILES
295 | tools::UNIFIED_FILE
296 )
297}
298
299#[must_use]
300fn tool_supports_edit_permission(tool: &str) -> bool {
301 matches!(
302 tool.trim(),
303 tools::EDIT_FILE | tools::APPLY_PATCH | tools::SEARCH_REPLACE | tools::FILE_OP | tools::UNIFIED_FILE
304 )
305}
306
307#[must_use]
308fn tool_supports_write_permission(tool: &str) -> bool {
309 matches!(
310 tool.trim(),
311 tools::WRITE_FILE
312 | tools::CREATE_FILE
313 | tools::DELETE_FILE
314 | tools::MOVE_FILE
315 | tools::COPY_FILE
316 | tools::UNIFIED_FILE
317 )
318}
319
320#[must_use]
321fn tool_supports_bash_permission(tool: &str) -> bool {
322 matches!(
323 tool.trim(),
324 tools::UNIFIED_EXEC
325 | tools::SHELL
326 | tools::EXEC_COMMAND
327 | tools::WRITE_STDIN
328 | tools::RUN_PTY_CMD
329 | tools::EXEC_PTY_CMD
330 | tools::CREATE_PTY_SESSION
331 | tools::LIST_PTY_SESSIONS
332 | tools::CLOSE_PTY_SESSION
333 | tools::SEND_PTY_INPUT
334 | tools::READ_PTY_SESSION
335 | tools::RESIZE_PTY_SESSION
336 | tools::EXECUTE_CODE
337 )
338}
339
340#[must_use]
341fn tool_supports_web_fetch_permission(tool: &str) -> bool {
342 matches!(tool.trim(), tools::WEB_FETCH | tools::FETCH_URL)
343}
344
345#[must_use]
346fn canonical_mcp_rule_matches_tool(rule: &str, tool: &str) -> bool {
347 let Some(rule) = rule.trim().strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) else {
348 return false;
349 };
350 let Some(tool) = tool.trim().strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) else {
351 return false;
352 };
353
354 match rule.split_once("__") {
355 Some((server, "*")) => tool.starts_with(&format!("{server}__")),
356 Some(_) => tool == rule,
357 None => tool == rule || tool.starts_with(&format!("{rule}__")),
358 }
359}
360
361fn merge_child_hooks(child: &mut VTCodeConfig, hooks: Option<&HooksConfig>) {
364 let Some(hooks) = hooks else {
365 return;
366 };
367
368 child.hooks.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
369 child
370 .hooks
371 .lifecycle
372 .session_start
373 .extend(hooks.lifecycle.session_start.clone());
374 child.hooks.lifecycle.session_end.extend(hooks.lifecycle.session_end.clone());
375 child
376 .hooks
377 .lifecycle
378 .user_prompt_submit
379 .extend(hooks.lifecycle.user_prompt_submit.clone());
380 child.hooks.lifecycle.pre_tool_use.extend(hooks.lifecycle.pre_tool_use.clone());
381 child
382 .hooks
383 .lifecycle
384 .post_tool_use
385 .extend(hooks.lifecycle.post_tool_use.clone());
386 child
387 .hooks
388 .lifecycle
389 .permission_request
390 .extend(hooks.lifecycle.permission_request.clone());
391 child.hooks.lifecycle.pre_compact.extend(hooks.lifecycle.pre_compact.clone());
392 child.hooks.lifecycle.stop.extend(
394 hooks
395 .lifecycle
396 .stop
397 .clone()
398 .into_iter()
399 .chain(hooks.lifecycle.task_completion.clone())
400 .chain(hooks.lifecycle.task_completed.clone()),
401 );
402 child.hooks.lifecycle.notification.extend(hooks.lifecycle.notification.clone());
403}
404
405fn merge_child_mcp_servers(
406 providers: &mut Vec<McpProviderConfig>,
407 parent_providers: &[McpProviderConfig],
408 servers: &[SubagentMcpServer],
409) {
410 for server in servers {
411 match server {
412 SubagentMcpServer::Named(name) => {
413 if providers.iter().any(|provider| provider.name == *name) {
414 continue;
415 }
416 if let Some(parent_provider) = parent_providers.iter().find(|provider| provider.name == *name) {
417 providers.push(parent_provider.clone());
418 }
419 }
420 SubagentMcpServer::Inline(definition) => {
421 for (name, value) in definition {
422 let provider = inline_mcp_provider(name, value);
423 if let Some(provider) = provider {
424 providers.retain(|existing| existing.name != provider.name);
425 providers.push(provider);
426 }
427 }
428 }
429 }
430 }
431}
432
433fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
434 let object = value.as_object()?;
435 let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
436 payload.insert("name".to_string(), serde_json::Value::String(name.to_string()));
437 for (key, value) in object {
438 if key == "type" {
439 continue;
440 }
441 payload.insert(key.clone(), value.clone());
442 }
443 if payload.contains_key("command") && !payload.contains_key("args") {
444 payload.insert("args".to_string(), serde_json::Value::Array(Vec::new()));
445 }
446 serde_json::from_value(serde_json::Value::Object(payload)).ok()
447}
448
449const FINAL_RESPONSE_CONTRACT: &str = "Return your final response using this exact Markdown contract:\n\n\
452## Summary\n\
453- [Concise outcome]\n\n\
454## Facts\n\
455- [Grounded fact]\n\n\
456## Touched Files\n\
457- [Relative path]\n\n\
458## Verification\n\
459- [Check performed or still needed]\n\n\
460## Open Questions\n\
461- [Any unresolved question]\n\n\
462Use `- None` for empty sections. Keep it concise and grounded in the work you actually performed. \
463If your agent instructions or the task define their own response format, follow that format instead.";
464
465const READ_ONLY_TOOL_REMINDER: &str = "Tool reminder: this child has a read-only tool set, and calls outside it are \
466denied. Use advanced `code_search` for a focused literal query with bounded filters, and `list_skills` / \
467`load_skill_resource` for repository skills that are already loaded. If these tools cannot answer the task, report what \
468is missing in your final response.";
469
470const READ_ONLY_PLANNING_WORKFLOW_REMINDER: &str = "Planning workflow tools and mutating tools are not exposed to this \
471child, and a denied call returns the same denial on retry. When a call is denied, change approach or report the \
472blocker.";
473
474const WRITE_TOOL_REMINDER: &str = "Tool reminder: use `exec_command` with targeted commands for workspace discovery \
475and file reading. Use advanced `code_search` for definitions, syntactic usages, text, or matching paths. When `exec_command` returns a live session, \
476continue or poll it with `write_stdin`. Use `exec_command` with `git diff --name-only` or `git diff --stat` when reviewing \
477current changes.";
478
479fn write_synthesis_reminder() -> String {
483 format!(
484 "Exploration budget: this run ends when it reaches {SUBAGENT_MAX_TOTAL_READONLY_CALLS} read-only calls in total, \
485{SUBAGENT_NAVIGATION_HARD_STOP_STREAK} consecutive reads/searches without an edit or a non-read command, or repeated \
486reads of the same file with little argument variation. A run stopped this way ends before you write the final \
487response, so start implementing once you have enough context; if you are re-reading one file at different \
488offsets, write up what you have instead."
489 )
490}
491
492pub fn compose_subagent_instructions(spec: &SubagentSpec, memory_appendix: Option<String>) -> String {
493 compose_subagent_runtime_instructions(&ResolvedAgentRuntimeView::from_spec(spec), memory_appendix)
494}
495
496fn compose_subagent_runtime_instructions(
497 runtime: &ResolvedAgentRuntimeView,
498 memory_appendix: Option<String>,
499) -> String {
500 let mut sections = Vec::new();
501 if !runtime.instructions.trim().is_empty() {
502 sections.push(runtime.instructions.trim().to_string());
503 }
504 sections.push(FINAL_RESPONSE_CONTRACT.to_string());
505
506 if is_runtime_read_only(runtime) {
507 sections.push(READ_ONLY_TOOL_REMINDER.to_string());
508 sections.push(READ_ONLY_PLANNING_WORKFLOW_REMINDER.to_string());
509 } else {
510 sections.push(WRITE_TOOL_REMINDER.to_string());
511 sections.push(write_synthesis_reminder());
512 }
513
514 if !runtime.skills.is_empty() {
515 sections.push(format!(
516 "Preloaded skill names: {}. Use their established repository conventions.",
517 runtime.skills.join(", ")
518 ));
519 }
520 if let Some(memory_appendix) = memory_appendix
521 && !memory_appendix.trim().is_empty()
522 {
523 sections.push(memory_appendix);
524 }
525 sections.join("\n\n")
526}
527
528fn is_runtime_read_only(runtime: &ResolvedAgentRuntimeView) -> bool {
529 runtime.read_only
530}
531
532pub fn build_subagent_archive_metadata(
533 workspace_root: &Path,
534 model: &str,
535 provider: &str,
536 theme: &str,
537 reasoning_effort: &str,
538 parent_session_id: &str,
539 forked: bool,
540) -> SessionArchiveMetadata {
541 build_thread_archive_metadata(workspace_root, model, provider, theme, reasoning_effort)
542 .with_parent_session_id(parent_session_id.to_string())
543 .with_fork_mode(if forked {
544 SessionForkMode::FullCopy
545 } else {
546 SessionForkMode::Summarized
547 })
548}
549
550pub fn filter_child_tools(
553 spec: &SubagentSpec,
554 definitions: Vec<ToolDefinition>,
555 read_only: bool,
556 allow_nested_delegation: bool,
557) -> Vec<ToolDefinition> {
558 let allowed = spec
559 .tools
560 .as_ref()
561 .map(|tools| tools.iter().map(|tool| tool.to_ascii_lowercase()).collect::<Vec<_>>());
562 let denied = spec
563 .disallowed_tools
564 .iter()
565 .map(|tool| tool.to_ascii_lowercase())
566 .collect::<Vec<_>>();
567 let blocked = blocked_child_tool_names(allow_nested_delegation);
568
569 definitions
570 .into_iter()
571 .filter(|tool| {
572 let name = tool.function_name().to_ascii_lowercase();
573 if blocked.iter().any(|blocked| *blocked == name) {
574 return false;
575 }
576 if denied.iter().any(|entry| entry == &name) {
577 return false;
578 }
579 if let Some(allowed) = allowed.as_ref()
580 && !allowed.iter().any(|entry| entry == &name)
581 {
582 return false;
583 }
584 if read_only {
585 return NON_MUTATING_TOOL_PREFIXES.iter().any(|candidate| *candidate == name);
586 }
587 true
588 })
589 .collect()
590}
591#[cfg(test)]
592mod slice4_tests {
593 use super::{READ_ONLY_TOOL_REMINDER, WRITE_TOOL_REMINDER, build_child_config, filter_child_tools};
594 use crate::config::VTCodeConfig;
595 use crate::config::constants::tools;
596 use crate::llm::provider::ToolDefinition;
597
598 fn definition(name: &str) -> ToolDefinition {
599 ToolDefinition::function(name.to_string(), name.to_string(), serde_json::json!({"type": "object"}))
600 }
601
602 #[test]
603 fn explorer_keeps_public_read_tools_through_intersection_and_filtering() {
604 let mut parent = VTCodeConfig::default();
605 parent.permissions.allow = vec!["Read".to_string()];
606 let spec = vtcode_config::builtin_subagents()
607 .into_iter()
608 .find(|spec| spec.name == "explorer")
609 .expect("explorer");
610
611 let child = build_child_config(&parent, &spec, "small", None, false);
612 assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
613
614 let filtered = filter_child_tools(
615 &spec,
616 vec![
617 definition(tools::CODE_SEARCH),
618 definition(tools::LOAD_SKILL),
619 definition(tools::EXEC_COMMAND),
620 definition(tools::APPLY_PATCH),
621 definition(tools::WRITE_STDIN),
622 ],
623 spec.is_read_only(),
624 false,
625 );
626 let names = filtered.iter().map(ToolDefinition::function_name).collect::<Vec<_>>();
627
628 assert_eq!(names, vec![tools::CODE_SEARCH]);
629 }
630
631 #[test]
632 fn read_only_tool_reminder_names_only_exposed_read_only_tools() {
633 for tool in ["code_search", "list_skills", "load_skill_resource"] {
634 assert!(READ_ONLY_TOOL_REMINDER.contains(&format!("`{tool}`")));
635 }
636 assert!(!READ_ONLY_TOOL_REMINDER.contains("`load_skill`"));
637 assert!(!READ_ONLY_TOOL_REMINDER.contains("`exec_command`"));
638 assert!(!READ_ONLY_TOOL_REMINDER.contains("`write_stdin`"));
639 assert!(!READ_ONLY_TOOL_REMINDER.contains("search_dispatch"));
640 assert!(!READ_ONLY_TOOL_REMINDER.contains("command_session"));
641 }
642
643 #[test]
644 fn writable_tool_reminder_names_public_search_and_execution_tools() {
645 assert!(WRITE_TOOL_REMINDER.contains("`exec_command`"));
646 assert!(WRITE_TOOL_REMINDER.contains("`write_stdin`"));
647 assert!(WRITE_TOOL_REMINDER.contains("`code_search`"));
648 assert!(!WRITE_TOOL_REMINDER.contains("search_dispatch"));
649 assert!(!WRITE_TOOL_REMINDER.contains("command_session"));
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::config::constants::models;
657 use vtcode_config::core::permissions::PermissionDefault;
658 use vtcode_config::{AgentMode, IsolationMode, McpProviderConfig, SubagentMcpServer, SubagentSource, SubagentSpec};
659
660 fn test_subagent_spec() -> SubagentSpec {
661 SubagentSpec {
662 name: "test-agent".to_string(),
663 description: "Test agent".to_string(),
664 prompt: "Do the thing".to_string(),
665 tools: None,
666 disallowed_tools: Vec::new(),
667 model: None,
668 color: None,
669 reasoning_effort: None,
670 permissions: AgentPermissionsConfig::new(PermissionDefault::Ask),
671 skills: Vec::new(),
672 mcp_servers: Vec::new(),
673 hooks: None,
674 background: false,
675 mode: AgentMode::default(),
676 max_turns: None,
677 nickname_candidates: Vec::new(),
678 initial_prompt: None,
679 memory: None,
680 isolation: None,
681 aliases: Vec::new(),
682 source: SubagentSource::Builtin,
683 file_path: None,
684 warnings: Vec::new(),
685 tool_policy_overrides: BTreeMap::new(),
686 }
687 }
688
689 #[test]
690 fn child_config_uses_lightweight_default_profile() {
691 let mut parent = VTCodeConfig::default();
692 parent.agent.system_prompt_mode = SystemPromptMode::Specialized;
693 parent.agent.tool_documentation_mode = ToolDocumentationMode::Full;
694 parent.mcp.providers.push(McpProviderConfig::default());
695
696 let spec = test_subagent_spec();
697 let child = build_child_config(&parent, &spec, models::openai::GPT_5_6_SOL, None, false);
698
699 assert_eq!(
700 child.agent.system_prompt_mode,
701 SystemPromptMode::Minimal,
702 "subagent should default to Minimal system prompt mode"
703 );
704 assert_eq!(
705 child.agent.tool_documentation_mode,
706 ToolDocumentationMode::Minimal,
707 "subagent should default to Minimal tool documentation mode"
708 );
709 assert!(
710 child.mcp.providers.is_empty(),
711 "subagent should not inherit parent MCP providers unless explicitly requested"
712 );
713 }
714
715 #[test]
716 fn child_config_attaches_explicit_mcp_servers() {
717 let mut parent = VTCodeConfig::default();
718 parent.mcp.providers.push(McpProviderConfig::default());
719
720 let mut spec = test_subagent_spec();
721 parent.mcp.providers[0].name = "context7".to_string();
722 spec.mcp_servers = vec![SubagentMcpServer::Named("context7".to_string())];
723
724 let child = build_child_config(&parent, &spec, models::openai::GPT_5_6_SOL, None, false);
725
726 assert_eq!(child.mcp.providers.len(), 1);
727 assert_eq!(child.mcp.providers[0].name, "context7");
728 }
729
730 #[test]
733 fn resolve_child_mcp_providers_drops_unnamed_parent_servers() {
734 let mut parent = VTCodeConfig::default();
735 parent.mcp.providers.push(McpProviderConfig::default());
736
737 let spec = test_subagent_spec();
738 let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
739
740 let providers = resolve_child_mcp_providers(&parent, &runtime);
741 assert!(providers.is_empty(), "parent MCP providers must not leak into the child unless explicitly named");
742 }
743
744 #[test]
745 fn resolve_child_mcp_providers_keeps_named_parent_server() {
746 let mut parent = VTCodeConfig::default();
747 parent.mcp.providers.push(McpProviderConfig::default());
748 parent.mcp.providers[0].name = "context7".to_string();
749
750 let mut spec = test_subagent_spec();
751 spec.mcp_servers = vec![SubagentMcpServer::Named("context7".to_string())];
752 let runtime = ResolvedAgentRuntimeView::from_spec(&spec);
753
754 let providers = resolve_child_mcp_providers(&parent, &runtime);
755 assert_eq!(providers.len(), 1);
756 assert_eq!(providers[0].name, "context7");
757 }
758
759 #[tokio::test]
768 async fn default_subagent_bootstrap_tokens_are_materially_below_parent() {
769 use crate::config::constants::tools;
770 use crate::prompts::context::PromptContext;
771 use crate::prompts::system::compose_system_instruction_with_report;
772 use crate::skills::model::{SkillMetadata, SkillScope};
773 use std::path::PathBuf;
774
775 let workspace = tempfile::TempDir::new().expect("workspace");
776
777 let mut parent = VTCodeConfig::default();
778 parent.agent.system_prompt_mode = SystemPromptMode::Default;
779 parent.agent.tool_documentation_mode = ToolDocumentationMode::Progressive;
780 parent.agent.include_temporal_context = false;
781 parent.agent.include_working_directory = true;
782 parent.agent.instruction_max_bytes = 0;
783
784 let mut ctx = PromptContext::default();
787 ctx.add_tool(tools::CODE_SEARCH.to_string());
788 ctx.add_tool(tools::EXEC_COMMAND.to_string());
789 ctx.add_tool(tools::APPLY_PATCH.to_string());
790 ctx.add_skill_metadata(SkillMetadata {
791 name: "skill-creator".to_string(),
792 description: "Create skills".to_string(),
793 short_description: None,
794 path: PathBuf::from("/tmp/skill-creator/SKILL.md"),
795 scope: SkillScope::System,
796 manifest: None,
797 });
798 ctx.set_current_directory(PathBuf::from("/workspace"));
799
800 let (_parent_text, parent_report) =
801 compose_system_instruction_with_report(workspace.path(), Some(&parent), Some(&ctx)).await;
802
803 let spec = test_subagent_spec();
804 let child = build_child_config(&parent, &spec, models::openai::GPT_5_6_SOL, None, false);
805
806 assert_eq!(child.agent.system_prompt_mode, SystemPromptMode::Minimal);
808 assert_eq!(child.agent.tool_documentation_mode, ToolDocumentationMode::Minimal,);
809 assert!(!child.agent.include_temporal_context);
812 assert_eq!(child.agent.instruction_max_bytes, 0);
813
814 let (_child_text, child_report) =
815 compose_system_instruction_with_report(workspace.path(), Some(&child), Some(&ctx)).await;
816
817 let parent_tokens = parent_report.token_estimate;
818 let child_tokens = child_report.token_estimate;
819 assert!(parent_tokens > 0, "parent prompt must be non-empty for a meaningful ratio");
820 assert!(
832 child_tokens < parent_tokens,
833 "default subagent composed prompt ({child_tokens}) must be strictly cheaper \
834 than parent ({parent_tokens})"
835 );
836 assert!(
837 child_tokens * 20 <= parent_tokens * 17,
838 "default subagent composed prompt ({child_tokens}) must be <= 85% of parent \
839 ({parent_tokens}); a bloat of the Minimal profile is a regression"
840 );
841 }
842}