Skip to main content

vtcode_core/subagents/
config.rs

1use anyhow::Result;
2use std::path::Path;
3use vtcode_config::{
4    HooksConfig, McpProviderConfig, PermissionMode, SubagentMcpServer, SubagentSpec,
5};
6
7use super::constants::{
8    NON_MUTATING_TOOL_PREFIXES, SUBAGENT_MIN_BACKGROUND_MAX_TURNS, SUBAGENT_MIN_MAX_TURNS,
9    SUBAGENT_TOOL_NAMES,
10};
11use crate::config::VTCodeConfig;
12use crate::config::constants::tools;
13use crate::config::models::ModelId;
14use crate::config::types::ReasoningEffortLevel;
15use crate::core::threads::build_thread_archive_metadata;
16use crate::llm::provider::ToolDefinition;
17use crate::tools::mcp::MCP_QUALIFIED_TOOL_PREFIX;
18use crate::utils::session_archive::{SessionArchiveMetadata, SessionForkMode};
19
20// ─── Child Config Building ─────────────────────────────────────────────────
21
22pub fn build_child_config(
23    parent: &VTCodeConfig,
24    spec: &SubagentSpec,
25    model: &str,
26    max_turns: Option<usize>,
27) -> VTCodeConfig {
28    let mut child = parent.clone();
29    child.agent.default_model = model.to_string();
30    if let Some(mode) = spec.permission_mode {
31        child.permissions.default_mode =
32            clamp_permission_mode(parent.permissions.default_mode, mode);
33    }
34    if let Some(max_turns) = normalize_child_max_turns(max_turns) {
35        child.automation.full_auto.max_turns = max_turns;
36    }
37
38    let mut allowed_tools = spec.tools.clone().unwrap_or_default();
39    if !allowed_tools.is_empty() {
40        allowed_tools.retain(|tool| !SUBAGENT_TOOL_NAMES.iter().any(|blocked| blocked == tool));
41        child.permissions.allow =
42            intersect_allowed_tools(&parent.permissions.allow, &allowed_tools);
43    }
44
45    let mut disallowed_tools = parent.permissions.deny.clone();
46    disallowed_tools.extend(spec.disallowed_tools.clone());
47    for tool in SUBAGENT_TOOL_NAMES {
48        if !disallowed_tools.iter().any(|entry| entry == tool) {
49            disallowed_tools.push((*tool).to_string());
50        }
51    }
52    child.permissions.deny = disallowed_tools;
53    merge_child_hooks(&mut child, spec.hooks.as_ref());
54    merge_child_mcp_servers(&mut child, spec.mcp_servers.as_slice());
55    child
56}
57
58pub fn normalize_child_max_turns(max_turns: Option<usize>) -> Option<usize> {
59    max_turns.map(|value| value.max(SUBAGENT_MIN_MAX_TURNS))
60}
61
62pub fn normalize_background_child_max_turns(
63    max_turns: Option<usize>,
64    background: bool,
65) -> Option<usize> {
66    let normalized = normalize_child_max_turns(max_turns);
67    if background {
68        normalized.map(|value| value.max(SUBAGENT_MIN_BACKGROUND_MAX_TURNS))
69    } else {
70        normalized
71    }
72}
73
74pub fn prepare_child_runtime_config(
75    parent: &VTCodeConfig,
76    spec: &SubagentSpec,
77    parent_model: &str,
78    parent_provider: &str,
79    parent_reasoning_effort: ReasoningEffortLevel,
80    max_turns: Option<usize>,
81    model_override: Option<&str>,
82    reasoning_override: Option<&str>,
83    resolve_model: impl FnOnce(
84        &VTCodeConfig,
85        &str,
86        &str,
87        Option<&str>,
88        Option<&str>,
89        &str,
90    ) -> Result<ModelId>,
91) -> Result<(ModelId, ReasoningEffortLevel, VTCodeConfig)> {
92    let resolved_model = resolve_model(
93        parent,
94        parent_model,
95        parent_provider,
96        model_override,
97        spec.model.as_deref(),
98        spec.name.as_str(),
99    )?;
100    let mut child_cfg = build_child_config(parent, spec, resolved_model.as_str(), max_turns);
101    let child_reasoning_effort = reasoning_override
102        .and_then(ReasoningEffortLevel::parse)
103        .or_else(|| {
104            spec.reasoning_effort
105                .as_deref()
106                .and_then(ReasoningEffortLevel::parse)
107        })
108        .unwrap_or(parent_reasoning_effort);
109    child_cfg.agent.default_model = resolved_model.to_string();
110    child_cfg.agent.reasoning_effort = child_reasoning_effort;
111    Ok((resolved_model, child_reasoning_effort, child_cfg))
112}
113
114// ─── Permission Handling ────────────────────────────────────────────────────
115
116fn clamp_permission_mode(parent: PermissionMode, requested: PermissionMode) -> PermissionMode {
117    if matches!(
118        parent,
119        PermissionMode::Auto | PermissionMode::BypassPermissions
120    ) {
121        return parent;
122    }
123    if permission_rank(requested) <= permission_rank(parent) {
124        requested
125    } else {
126        parent
127    }
128}
129
130fn permission_rank(mode: PermissionMode) -> u8 {
131    match mode {
132        PermissionMode::DontAsk => 0,
133        PermissionMode::Plan => 1,
134        PermissionMode::Default => 2,
135        PermissionMode::AcceptEdits => 3,
136        PermissionMode::Auto => 4,
137        PermissionMode::BypassPermissions => 5,
138    }
139}
140
141fn intersect_allowed_tools(parent_allowed: &[String], spec_allowed: &[String]) -> Vec<String> {
142    if parent_allowed.is_empty() {
143        return spec_allowed.to_vec();
144    }
145
146    parent_allowed
147        .iter()
148        .filter(|rule| parent_rule_matches_spec_tools(rule, spec_allowed))
149        .cloned()
150        .collect()
151}
152
153fn parent_rule_matches_spec_tools(rule: &str, spec_allowed: &[String]) -> bool {
154    let rule = rule.trim();
155    if rule.is_empty() {
156        return false;
157    }
158
159    let prefix = rule
160        .split_once('(')
161        .map_or(rule, |(prefix, _)| prefix)
162        .trim();
163    match prefix.to_ascii_lowercase().as_str() {
164        "read" => spec_allowed
165            .iter()
166            .any(|tool| tool_supports_read_permission(tool)),
167        "edit" => spec_allowed
168            .iter()
169            .any(|tool| tool_supports_edit_permission(tool)),
170        "write" => spec_allowed
171            .iter()
172            .any(|tool| tool_supports_write_permission(tool)),
173        "bash" => spec_allowed
174            .iter()
175            .any(|tool| tool_supports_bash_permission(tool)),
176        "webfetch" => spec_allowed
177            .iter()
178            .any(|tool| tool_supports_web_fetch_permission(tool)),
179        _ if rule.starts_with(MCP_QUALIFIED_TOOL_PREFIX) => spec_allowed
180            .iter()
181            .any(|tool| canonical_mcp_rule_matches_tool(rule, tool)),
182        _ if rule.contains(['(', ')']) => false,
183        _ => spec_allowed
184            .iter()
185            .any(|tool| tool.trim().eq_ignore_ascii_case(rule)),
186    }
187}
188
189#[must_use]
190fn tool_supports_read_permission(tool: &str) -> bool {
191    matches!(
192        tool.trim(),
193        tools::READ_FILE
194            | tools::GREP_FILE
195            | tools::LIST_FILES
196            | tools::UNIFIED_SEARCH
197            | tools::UNIFIED_FILE
198    )
199}
200
201#[must_use]
202fn tool_supports_edit_permission(tool: &str) -> bool {
203    matches!(
204        tool.trim(),
205        tools::EDIT_FILE
206            | tools::APPLY_PATCH
207            | tools::SEARCH_REPLACE
208            | tools::FILE_OP
209            | tools::UNIFIED_FILE
210    )
211}
212
213#[must_use]
214fn tool_supports_write_permission(tool: &str) -> bool {
215    matches!(
216        tool.trim(),
217        tools::WRITE_FILE
218            | tools::CREATE_FILE
219            | tools::DELETE_FILE
220            | tools::MOVE_FILE
221            | tools::COPY_FILE
222            | tools::UNIFIED_FILE
223    )
224}
225
226#[must_use]
227fn tool_supports_bash_permission(tool: &str) -> bool {
228    matches!(
229        tool.trim(),
230        tools::UNIFIED_EXEC
231            | tools::SHELL
232            | tools::EXEC_COMMAND
233            | tools::WRITE_STDIN
234            | tools::RUN_PTY_CMD
235            | tools::EXEC_PTY_CMD
236            | tools::CREATE_PTY_SESSION
237            | tools::LIST_PTY_SESSIONS
238            | tools::CLOSE_PTY_SESSION
239            | tools::SEND_PTY_INPUT
240            | tools::READ_PTY_SESSION
241            | tools::RESIZE_PTY_SESSION
242            | tools::EXECUTE_CODE
243    )
244}
245
246#[must_use]
247fn tool_supports_web_fetch_permission(tool: &str) -> bool {
248    matches!(
249        tool.trim(),
250        tools::WEB_FETCH | tools::FETCH_URL | tools::UNIFIED_SEARCH
251    )
252}
253
254#[must_use]
255fn canonical_mcp_rule_matches_tool(rule: &str, tool: &str) -> bool {
256    let Some(rule) = rule.trim().strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) else {
257        return false;
258    };
259    let Some(tool) = tool.trim().strip_prefix(MCP_QUALIFIED_TOOL_PREFIX) else {
260        return false;
261    };
262
263    match rule.split_once("__") {
264        Some((server, "*")) => tool.starts_with(&format!("{server}__")),
265        Some(_) => tool == rule,
266        None => tool == rule || tool.starts_with(&format!("{rule}__")),
267    }
268}
269
270// ─── Hook & MCP Merging ─────────────────────────────────────────────────────
271
272fn merge_child_hooks(child: &mut VTCodeConfig, hooks: Option<&HooksConfig>) {
273    let Some(hooks) = hooks else {
274        return;
275    };
276
277    child.hooks.lifecycle.quiet_success_output |= hooks.lifecycle.quiet_success_output;
278    child
279        .hooks
280        .lifecycle
281        .session_start
282        .extend(hooks.lifecycle.session_start.clone());
283    child
284        .hooks
285        .lifecycle
286        .session_end
287        .extend(hooks.lifecycle.session_end.clone());
288    child
289        .hooks
290        .lifecycle
291        .user_prompt_submit
292        .extend(hooks.lifecycle.user_prompt_submit.clone());
293    child
294        .hooks
295        .lifecycle
296        .pre_tool_use
297        .extend(hooks.lifecycle.pre_tool_use.clone());
298    child
299        .hooks
300        .lifecycle
301        .post_tool_use
302        .extend(hooks.lifecycle.post_tool_use.clone());
303    child
304        .hooks
305        .lifecycle
306        .permission_request
307        .extend(hooks.lifecycle.permission_request.clone());
308    child
309        .hooks
310        .lifecycle
311        .pre_compact
312        .extend(hooks.lifecycle.pre_compact.clone());
313    // Unified stop hook merging: stop + task_completion + task_completed
314    child.hooks.lifecycle.stop.extend(
315        hooks
316            .lifecycle
317            .stop
318            .clone()
319            .into_iter()
320            .chain(hooks.lifecycle.task_completion.clone())
321            .chain(hooks.lifecycle.task_completed.clone()),
322    );
323    child
324        .hooks
325        .lifecycle
326        .notification
327        .extend(hooks.lifecycle.notification.clone());
328}
329
330fn merge_child_mcp_servers(child: &mut VTCodeConfig, servers: &[SubagentMcpServer]) {
331    for server in servers {
332        match server {
333            SubagentMcpServer::Named(name) => {
334                if child
335                    .mcp
336                    .providers
337                    .iter()
338                    .any(|provider| provider.name == *name)
339                {
340                    continue;
341                }
342            }
343            SubagentMcpServer::Inline(definition) => {
344                for (name, value) in definition {
345                    let provider = inline_mcp_provider(name, value);
346                    if let Some(provider) = provider {
347                        child
348                            .mcp
349                            .providers
350                            .retain(|existing| existing.name != provider.name);
351                        child.mcp.providers.push(provider);
352                    }
353                }
354            }
355        }
356    }
357}
358
359fn inline_mcp_provider(name: &str, value: &serde_json::Value) -> Option<McpProviderConfig> {
360    let object = value.as_object()?;
361    let mut payload = serde_json::Map::with_capacity(object.len().saturating_add(1));
362    payload.insert(
363        "name".to_string(),
364        serde_json::Value::String(name.to_string()),
365    );
366    for (key, value) in object {
367        payload.insert(key.clone(), value.clone());
368    }
369    serde_json::from_value(serde_json::Value::Object(payload)).ok()
370}
371
372// ─── Instructions Composition ───────────────────────────────────────────────
373
374const FINAL_RESPONSE_CONTRACT: &str = "Return your final response using this exact Markdown contract:\n\n\
375## Summary\n\
376- [Concise outcome]\n\n\
377## Facts\n\
378- [Grounded fact]\n\n\
379## Touched Files\n\
380- [Relative path]\n\n\
381## Verification\n\
382- [Check performed or still needed]\n\n\
383## Open Questions\n\
384- [Any unresolved question]\n\n\
385Use `- None` for empty sections. Keep it concise and grounded in the work you actually performed.";
386
387const READ_ONLY_TOOL_REMINDER: &str = "Tool reminder: stay inside the exposed read-only tool set for this child. \
388Do not guess hidden or legacy helpers such as `list_files`, `read_file`, `unified_file`, or `unified_exec` when they \
389are not visible. For workspace discovery here, prefer `unified_search`; if that is insufficient, report the blocker \
390instead of retrying denied calls.";
391
392const READ_ONLY_PLAN_MODE_REMINDER: &str = "This delegated agent already runs with a read-only tool surface. \
393Do not try to enter or exit plan mode, do not call hidden mutating tools, and do not retry the same denied tool \
394call; adjust strategy or report the blocker instead.";
395
396const WRITE_TOOL_REMINDER: &str = "Tool reminder: `list_files` on the workspace root (`.`) is blocked, and \
397`list_files` already uses search internally. Do not pair `list_files` with `unified_search` in the same batch. \
398Use a specific subdirectory, `unified_search` for workspace-wide discovery, or `unified_exec` with \
399`git diff --name-only` / `git diff --stat` when reviewing current changes.";
400
401pub fn compose_subagent_instructions(
402    spec: &SubagentSpec,
403    memory_appendix: Option<String>,
404) -> String {
405    let mut sections = Vec::new();
406    if !spec.prompt.trim().is_empty() {
407        sections.push(spec.prompt.trim().to_string());
408    }
409    sections.push(FINAL_RESPONSE_CONTRACT.to_string());
410
411    if spec.is_read_only() {
412        sections.push(READ_ONLY_TOOL_REMINDER.to_string());
413        sections.push(READ_ONLY_PLAN_MODE_REMINDER.to_string());
414    } else {
415        sections.push(WRITE_TOOL_REMINDER.to_string());
416    }
417
418    if !spec.skills.is_empty() {
419        sections.push(format!(
420            "Preloaded skill names: {}. Use their established repository conventions.",
421            spec.skills.join(", ")
422        ));
423    }
424    if let Some(memory_appendix) = memory_appendix
425        && !memory_appendix.trim().is_empty()
426    {
427        sections.push(memory_appendix);
428    }
429    sections.join("\n\n")
430}
431
432pub fn build_subagent_archive_metadata(
433    workspace_root: &Path,
434    model: &str,
435    provider: &str,
436    theme: &str,
437    reasoning_effort: &str,
438    parent_session_id: &str,
439    forked: bool,
440) -> SessionArchiveMetadata {
441    build_thread_archive_metadata(workspace_root, model, provider, theme, reasoning_effort)
442        .with_parent_session_id(parent_session_id.to_string())
443        .with_fork_mode(if forked {
444            SessionForkMode::FullCopy
445        } else {
446            SessionForkMode::Summarized
447        })
448}
449
450// ─── Tool Filtering ─────────────────────────────────────────────────────────
451
452pub fn filter_child_tools(
453    spec: &SubagentSpec,
454    definitions: Vec<ToolDefinition>,
455    read_only: bool,
456) -> Vec<ToolDefinition> {
457    let allowed = spec.tools.as_ref().map(|tools| {
458        tools
459            .iter()
460            .map(|tool| tool.to_ascii_lowercase())
461            .collect::<Vec<_>>()
462    });
463    let denied = spec
464        .disallowed_tools
465        .iter()
466        .map(|tool| tool.to_ascii_lowercase())
467        .collect::<Vec<_>>();
468
469    definitions
470        .into_iter()
471        .filter(|tool| {
472            let name = tool.function_name().to_ascii_lowercase();
473            if SUBAGENT_TOOL_NAMES.iter().any(|blocked| *blocked == name) {
474                return false;
475            }
476            if denied.iter().any(|entry| entry == &name) {
477                return false;
478            }
479            if let Some(allowed) = allowed.as_ref()
480                && !allowed.iter().any(|entry| entry == &name)
481            {
482                return false;
483            }
484            if read_only {
485                return NON_MUTATING_TOOL_PREFIXES
486                    .iter()
487                    .any(|candidate| *candidate == name);
488            }
489            true
490        })
491        .collect()
492}