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
20pub 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
114fn 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
270fn 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 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
372const 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
450pub 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}