Skip to main content

vtcode_core/subagents/
config.rs

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
75// ─── Child Config Building ─────────────────────────────────────────────────
76
77pub 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    // A delegated role comes from its own spec, not the parent's primary role.
102    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 a lightweight default profile so a delegated child does not replay
106    // the parent bootstrap cost on every turn. This is currently a fixed
107    // default; a future enhancement may let a subagent spec opt into a heavier
108    // profile via explicit `system_prompt_mode`/`tool_documentation_mode`
109    // fields, but today the lightweight profile is always applied.
110    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    // Drop parent MCP providers by default; only attach servers explicitly
117    // requested by the subagent spec. This prevents multiplying MCP schema
118    // tax across every child. (H1: intentional behavioral change — specs that
119    // need a parent MCP server must declare it via `mcp_servers`.)
120    child.mcp.providers = resolve_child_mcp_providers(parent, runtime);
121    child
122}
123
124/// Forces the minimal system-prompt and tool-documentation modes for a
125/// subagent child config. Isolated so the subagent profile contract is
126/// testable without building a full runtime.
127fn 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
132/// Returns the subagent-internal tool names blocked from a child at the given
133/// nesting policy. When nested delegation is allowed only the background
134/// subprocess alias is blocked; otherwise every subagent-lifecycle tool is.
135///
136/// Single source of truth so the deny-list, allow-list, and wire-definition
137/// filters cannot diverge on the blocked set.
138fn 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
146/// Resolves the child's allow-list. When the spec declares tools, the child
147/// allow-list is the intersection of the parent allow-list and the declared
148/// tools (with subagent-internal tools removed unless nested delegation is
149/// allowed). When the spec declares nothing, the parent allow-list is
150/// inherited unchanged.
151fn 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
168/// Resolves the child's deny-list: the parent deny-list, extended with the
169/// spec's disallowed tools and the subagent-internal tools blocked at this
170/// nesting level. When nested delegation is allowed, only the
171/// background-subprocess alias stays blocked; the delegation tools are gated
172/// by the runtime depth check instead.
173fn 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
189/// Resolves the child's MCP providers. Parent providers are NOT inherited;
190/// only servers named or inlined by the spec are attached. This keeps the
191/// child bootstrap lean and avoids replaying the parent's MCP schema tax.
192fn 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
361// ─── Hook & MCP Merging ─────────────────────────────────────────────────────
362
363fn 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    // Unified stop hook merging: stop + task_completion + task_completed
393    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
449// ─── Instructions Composition ───────────────────────────────────────────────
450
451const 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
479/// Writable children run with the loop detector in subagent mode; its hard
480/// stops end the run before the child writes its final response, so state the
481/// real limits instead of a bare "stop reading" order.
482fn 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
550// ─── Tool Filtering ─────────────────────────────────────────────────────────
551
552pub 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    /// Guard-rail test for the extracted MCP-resolution helper: it must
731    /// isolatedly drop every parent provider unless the spec names it.
732    #[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    /// Phase 4.3 guard-rail: a default subagent's composed system prompt must
760    /// bootstrap materially cheaper than its parent's. The lightweight profile
761    /// (`apply_subagent_lightweight_profile`) forces `Minimal` system-prompt and
762    /// tool-documentation modes; this measures the *actual* composed prompt
763    /// token estimate (the dominant bootstrap cost) rather than just the config
764    /// shape, which `child_config_uses_lightweight_default_profile` already
765    /// covers. Cache prefix-stable conditions (no temporal context, no project
766    /// docs) are used so the ratio reflects only the mode-driven difference.
767    #[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        // A non-trivial tool/skill surface so Progressive tool guidelines and
785        // the base contract are exercised on both sides.
786        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        // The lightweight profile is the contract under test.
807        assert_eq!(child.agent.system_prompt_mode, SystemPromptMode::Minimal);
808        assert_eq!(child.agent.tool_documentation_mode, ToolDocumentationMode::Minimal,);
809        // The deterministic env settings must be inherited so the ratio
810        // reflects only the mode-driven difference, not environmental noise.
811        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        // The composed system-prompt reduction from the lightweight profile is
821        // bounded by the shared base-contract content. Universal runtime
822        // guidance is deliberately shared by every profile, so the dominant
823        // subagent bootstrap win remains the tool-schema/MCP drop, guarded by
824        // `mcp_deferral_keeps_first_request_wire_payload_near_baseline` and
825        // `child_config_uses_lightweight_default_profile`. This test pins the
826        // system-prompt portion: the child must be materially (and strictly)
827        // cheaper, and a regression that bloats the Minimal profile above 85% of
828        // the Default composed prompt must fail here. The bar is 85% rather
829        // than 80% because lean cuts to the parent (shared contract/operating
830        // lines) narrow the ratio without bloating the child.
831        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}