Skip to main content

vtcode_core/subagents/
config.rs

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