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
71pub 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
308fn 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 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
416const 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
508pub 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}