1use anyhow::{Context, Result, anyhow, bail};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map as JsonMap, Value as JsonValue};
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::constants::tools;
9use crate::core::PermissionMode;
10use crate::hooks::{HookCommandConfig, HookCommandKind, HookGroupConfig, HooksConfig};
11
12const BUILTIN_DEFAULT_AGENT: &str = r#"You are the default VT Code execution subagent.
13
14Work directly, keep context isolated from the parent session, and return concise summaries.
15Match the repository's local patterns, verify changes, and avoid unrelated edits."#;
16
17const BUILTIN_EXPLORER_AGENT: &str = r#"You are a fast read-only exploration subagent.
18
19Search the codebase, inspect relevant files, and return concise findings with file references.
20Do not modify files or take mutating actions."#;
21
22const BUILTIN_PLAN_AGENT: &str = r#"You are a read-only planning research subagent.
23
24Gather the minimum repository context needed to support a plan or design decision.
25Return findings, risks, and constraints clearly; do not modify files."#;
26
27const BUILTIN_WORKER_AGENT: &str = r#"You are a write-capable worker subagent.
28
29Handle bounded implementation work, verify results, and return a concise outcome summary with
30any important risks or follow-up items."#;
31
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum SubagentSource {
36 Cli,
37 ProjectVtcode,
38 ProjectClaude,
39 ProjectCodex,
40 UserVtcode,
41 UserClaude,
42 UserCodex,
43 Plugin { plugin: String },
44 Builtin,
45}
46
47impl SubagentSource {
48 #[must_use]
49 pub const fn priority(&self) -> usize {
50 match self {
51 Self::Cli => 0,
52 Self::ProjectVtcode => 1,
53 Self::ProjectClaude => 2,
54 Self::ProjectCodex => 3,
55 Self::UserVtcode => 4,
56 Self::UserClaude => 5,
57 Self::UserCodex => 6,
58 Self::Plugin { .. } => 7,
59 Self::Builtin => 8,
60 }
61 }
62
63 #[must_use]
64 pub fn label(&self) -> String {
65 match self {
66 Self::Cli => "cli".to_string(),
67 Self::ProjectVtcode => "project:.vtcode".to_string(),
68 Self::ProjectClaude => "project:.claude".to_string(),
69 Self::ProjectCodex => "project:.codex".to_string(),
70 Self::UserVtcode => "user:~/.vtcode".to_string(),
71 Self::UserClaude => "user:~/.claude".to_string(),
72 Self::UserCodex => "user:~/.codex".to_string(),
73 Self::Plugin { plugin } => format!("plugin:{plugin}"),
74 Self::Builtin => "builtin".to_string(),
75 }
76 }
77
78 #[must_use]
79 pub const fn vtcode_native(&self) -> bool {
80 matches!(self, Self::ProjectVtcode | Self::UserVtcode | Self::Cli)
81 }
82}
83
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub enum SubagentMemoryScope {
88 User,
89 Project,
90 Local,
91}
92
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
95#[serde(untagged)]
96pub enum SubagentMcpServer {
97 Named(String),
98 Inline(BTreeMap<String, JsonValue>),
99}
100
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct SubagentSpec {
104 pub name: String,
105 pub description: String,
106 #[serde(default)]
107 pub prompt: String,
108 #[serde(default)]
109 pub tools: Option<Vec<String>>,
110 #[serde(default)]
111 pub disallowed_tools: Vec<String>,
112 #[serde(default)]
113 pub model: Option<String>,
114 #[serde(default)]
115 pub color: Option<String>,
116 #[serde(default)]
117 pub reasoning_effort: Option<String>,
118 #[serde(default)]
119 pub permission_mode: Option<PermissionMode>,
120 #[serde(default)]
121 pub skills: Vec<String>,
122 #[serde(default)]
123 pub mcp_servers: Vec<SubagentMcpServer>,
124 #[serde(default)]
125 pub hooks: Option<HooksConfig>,
126 #[serde(default)]
127 pub background: bool,
128 #[serde(default)]
129 pub max_turns: Option<usize>,
130 #[serde(default)]
131 pub nickname_candidates: Vec<String>,
132 #[serde(default)]
133 pub initial_prompt: Option<String>,
134 #[serde(default)]
135 pub memory: Option<SubagentMemoryScope>,
136 #[serde(default)]
137 pub isolation: Option<String>,
138 #[serde(default)]
139 pub aliases: Vec<String>,
140 pub source: SubagentSource,
141 #[serde(default)]
142 pub file_path: Option<PathBuf>,
143 #[serde(default)]
144 pub warnings: Vec<String>,
145}
146
147impl SubagentSpec {
148 #[must_use]
149 pub fn is_read_only(&self) -> bool {
150 if matches!(self.permission_mode, Some(PermissionMode::Plan)) {
151 return true;
152 }
153
154 let tools = self.tools.as_ref().map_or_else(Vec::new, Clone::clone);
155 let lower_tools = tools
156 .iter()
157 .map(|tool| tool.to_ascii_lowercase())
158 .collect::<Vec<_>>();
159 let lower_denied = self
160 .disallowed_tools
161 .iter()
162 .map(|tool| tool.to_ascii_lowercase())
163 .collect::<Vec<_>>();
164
165 let denies_writes = lower_denied
166 .iter()
167 .any(|tool| is_mutating_tool_name(tool.as_str()));
168
169 if self.tools.is_some() {
170 let exposes_mutation = lower_tools
171 .iter()
172 .any(|tool| is_mutating_tool_name(tool.as_str()));
173 !exposes_mutation
174 } else {
175 denies_writes
176 }
177 }
178
179 #[must_use]
180 pub fn matches_name(&self, candidate: &str) -> bool {
181 self.name.eq_ignore_ascii_case(candidate)
182 || self
183 .aliases
184 .iter()
185 .any(|alias| alias.eq_ignore_ascii_case(candidate))
186 }
187}
188
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
191pub struct BackgroundSubagentConfig {
192 #[serde(default = "default_background_subagents_enabled")]
193 pub enabled: bool,
194 #[serde(default)]
195 pub default_agent: Option<String>,
196 #[serde(default = "default_background_refresh_interval_ms")]
197 pub refresh_interval_ms: u64,
198 #[serde(default = "default_background_auto_restore")]
199 pub auto_restore: bool,
200 #[serde(default = "default_background_toggle_shortcut")]
201 pub toggle_shortcut: String,
202}
203
204impl Default for BackgroundSubagentConfig {
205 fn default() -> Self {
206 Self {
207 enabled: default_background_subagents_enabled(),
208 default_agent: None,
209 refresh_interval_ms: default_background_refresh_interval_ms(),
210 auto_restore: default_background_auto_restore(),
211 toggle_shortcut: default_background_toggle_shortcut(),
212 }
213 }
214}
215
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
218pub struct SubagentRuntimeLimits {
219 #[serde(default = "default_subagents_enabled")]
220 pub enabled: bool,
221 #[serde(default = "default_subagents_max_concurrent")]
222 pub max_concurrent: usize,
223 #[serde(default = "default_subagents_max_depth")]
224 pub max_depth: usize,
225 #[serde(default = "default_subagents_default_timeout_seconds")]
226 pub default_timeout_seconds: u64,
227 #[serde(default = "default_subagents_auto_delegate_read_only")]
228 pub auto_delegate_read_only: bool,
229 #[serde(default)]
230 pub background: BackgroundSubagentConfig,
231}
232
233impl Default for SubagentRuntimeLimits {
234 fn default() -> Self {
235 Self {
236 enabled: default_subagents_enabled(),
237 max_concurrent: default_subagents_max_concurrent(),
238 max_depth: default_subagents_max_depth(),
239 default_timeout_seconds: default_subagents_default_timeout_seconds(),
240 auto_delegate_read_only: default_subagents_auto_delegate_read_only(),
241 background: BackgroundSubagentConfig::default(),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Default)]
247pub struct DiscoveredSubagents {
248 pub effective: Vec<SubagentSpec>,
249 pub shadowed: Vec<SubagentSpec>,
250}
251
252#[derive(Debug, Clone, Default)]
253pub struct SubagentDiscoveryInput {
254 pub workspace_root: PathBuf,
255 pub cli_agents: Option<JsonValue>,
256 pub plugin_agent_files: Vec<(String, PathBuf)>,
257}
258
259impl SubagentDiscoveryInput {
260 #[must_use]
261 pub fn new(workspace_root: PathBuf) -> Self {
262 Self {
263 workspace_root,
264 cli_agents: None,
265 plugin_agent_files: Vec::new(),
266 }
267 }
268}
269
270pub fn discover_subagents(input: &SubagentDiscoveryInput) -> Result<DiscoveredSubagents> {
271 let mut discovered = Vec::new();
272 discovered.extend(builtin_subagents());
273
274 if let Some(home) = dirs::home_dir() {
275 discovered.extend(load_subagents_from_dir(
276 &home.join(".codex/agents"),
277 SubagentSource::UserCodex,
278 )?);
279 discovered.extend(load_subagents_from_dir(
280 &home.join(".claude/agents"),
281 SubagentSource::UserClaude,
282 )?);
283 discovered.extend(load_subagents_from_dir(
284 &home.join(".vtcode/agents"),
285 SubagentSource::UserVtcode,
286 )?);
287 }
288
289 discovered.extend(load_subagents_from_dir(
290 &input.workspace_root.join(".codex/agents"),
291 SubagentSource::ProjectCodex,
292 )?);
293 discovered.extend(load_subagents_from_dir(
294 &input.workspace_root.join(".claude/agents"),
295 SubagentSource::ProjectClaude,
296 )?);
297 discovered.extend(load_subagents_from_dir(
298 &input.workspace_root.join(".vtcode/agents"),
299 SubagentSource::ProjectVtcode,
300 )?);
301
302 for (plugin_name, path) in &input.plugin_agent_files {
303 if !path.exists() || !path.is_file() {
304 continue;
305 }
306 let source = SubagentSource::Plugin {
307 plugin: plugin_name.clone(),
308 };
309 discovered.push(load_subagent_from_file(path, source)?);
310 }
311
312 if let Some(cli_agents) = input.cli_agents.as_ref() {
313 discovered.extend(load_cli_agents(cli_agents)?);
314 }
315
316 discovered.sort_by_key(|spec| spec.source.priority());
317
318 let mut effective_by_name: BTreeMap<String, SubagentSpec> = BTreeMap::new();
319 let mut shadowed = Vec::new();
320 for spec in discovered {
321 if let Some(existing) = effective_by_name.get(spec.name.as_str()) {
322 if should_replace(existing, &spec) {
323 shadowed.push(existing.clone());
324 effective_by_name.insert(spec.name.clone(), spec);
325 } else {
326 shadowed.push(spec);
327 }
328 } else {
329 effective_by_name.insert(spec.name.clone(), spec);
330 }
331 }
332
333 Ok(DiscoveredSubagents {
334 effective: effective_by_name.into_values().collect(),
335 shadowed,
336 })
337}
338
339pub fn builtin_subagents() -> Vec<SubagentSpec> {
340 vec![
341 SubagentSpec {
342 name: "default".to_string(),
343 description: "Default inheriting subagent for general delegated work.".to_string(),
344 prompt: BUILTIN_DEFAULT_AGENT.to_string(),
345 tools: None,
346 disallowed_tools: Vec::new(),
347 model: Some("inherit".to_string()),
348 color: Some("blue".to_string()),
349 reasoning_effort: None,
350 permission_mode: None,
351 skills: Vec::new(),
352 mcp_servers: Vec::new(),
353 hooks: None,
354 background: false,
355 max_turns: None,
356 nickname_candidates: vec!["default".to_string()],
357 initial_prompt: None,
358 memory: None,
359 isolation: None,
360 aliases: Vec::new(),
361 source: SubagentSource::Builtin,
362 file_path: None,
363 warnings: Vec::new(),
364 },
365 SubagentSpec {
366 name: "explorer".to_string(),
367 description: "Read-only exploration specialist. Use proactively for code search, file discovery, and repository understanding.".to_string(),
368 prompt: BUILTIN_EXPLORER_AGENT.to_string(),
369 tools: Some(builtin_readonly_tool_ids()),
370 disallowed_tools: builtin_readonly_disallowed_tool_ids(),
371 model: Some("small".to_string()),
372 color: Some("cyan".to_string()),
373 reasoning_effort: Some("low".to_string()),
374 permission_mode: Some(PermissionMode::Plan),
375 skills: Vec::new(),
376 mcp_servers: Vec::new(),
377 hooks: None,
378 background: false,
379 max_turns: None,
380 nickname_candidates: vec!["explore".to_string(), "search".to_string()],
381 initial_prompt: None,
382 memory: None,
383 isolation: None,
384 aliases: vec!["explore".to_string()],
385 source: SubagentSource::Builtin,
386 file_path: None,
387 warnings: Vec::new(),
388 },
389 SubagentSpec {
390 name: "plan".to_string(),
391 description: "Read-only planning researcher. Use proactively while gathering context for implementation plans.".to_string(),
392 prompt: BUILTIN_PLAN_AGENT.to_string(),
393 tools: Some(builtin_readonly_tool_ids()),
394 disallowed_tools: builtin_readonly_disallowed_tool_ids(),
395 model: Some("inherit".to_string()),
396 color: Some("yellow".to_string()),
397 reasoning_effort: Some("medium".to_string()),
398 permission_mode: Some(PermissionMode::Plan),
399 skills: Vec::new(),
400 mcp_servers: Vec::new(),
401 hooks: None,
402 background: false,
403 max_turns: None,
404 nickname_candidates: vec!["planner".to_string()],
405 initial_prompt: None,
406 memory: None,
407 isolation: None,
408 aliases: Vec::new(),
409 source: SubagentSource::Builtin,
410 file_path: None,
411 warnings: Vec::new(),
412 },
413 SubagentSpec {
414 name: "worker".to_string(),
415 description: "Write-capable execution subagent for bounded implementation or multi-step action.".to_string(),
416 prompt: BUILTIN_WORKER_AGENT.to_string(),
417 tools: None,
418 disallowed_tools: Vec::new(),
419 model: Some("inherit".to_string()),
420 color: Some("magenta".to_string()),
421 reasoning_effort: None,
422 permission_mode: None,
423 skills: Vec::new(),
424 mcp_servers: Vec::new(),
425 hooks: None,
426 background: false,
427 max_turns: None,
428 nickname_candidates: vec!["general".to_string(), "worker".to_string()],
429 initial_prompt: None,
430 memory: None,
431 isolation: None,
432 aliases: vec!["general".to_string(), "general-purpose".to_string()],
433 source: SubagentSource::Builtin,
434 file_path: None,
435 warnings: Vec::new(),
436 },
437 ]
438}
439
440fn builtin_readonly_tool_ids() -> Vec<String> {
441 vec![
442 tools::UNIFIED_SEARCH.to_string(),
443 tools::UNIFIED_FILE.to_string(),
444 tools::UNIFIED_EXEC.to_string(),
445 ]
446}
447
448fn builtin_readonly_disallowed_tool_ids() -> Vec<String> {
449 vec![tools::UNIFIED_FILE.to_string()]
450}
451
452fn should_replace(existing: &SubagentSpec, candidate: &SubagentSpec) -> bool {
453 let existing_priority = existing.source.priority();
454 let candidate_priority = candidate.source.priority();
455 if candidate_priority != existing_priority {
456 return candidate_priority < existing_priority;
457 }
458
459 candidate.source.vtcode_native() && !existing.source.vtcode_native()
460}
461
462fn load_subagents_from_dir(dir: &Path, source: SubagentSource) -> Result<Vec<SubagentSpec>> {
463 if !dir.exists() || !dir.is_dir() {
464 return Ok(Vec::new());
465 }
466
467 let extension = match source {
468 SubagentSource::ProjectCodex | SubagentSource::UserCodex => "toml",
469 _ => "md",
470 };
471 let mut loaded = Vec::new();
472 for entry in fs::read_dir(dir)
473 .with_context(|| format!("failed to read subagent directory {}", dir.display()))?
474 {
475 let entry = entry?;
476 let path = entry.path();
477 if !path.is_file() {
478 continue;
479 }
480 if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
481 continue;
482 }
483 loaded.push(load_subagent_from_file(&path, source.clone())?);
484 }
485
486 Ok(loaded)
487}
488
489pub fn load_subagent_from_file(path: &Path, source: SubagentSource) -> Result<SubagentSpec> {
490 let content =
491 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
492 let mut spec = match source {
493 SubagentSource::ProjectCodex | SubagentSource::UserCodex => {
494 parse_codex_toml_subagent(&content, source.clone())?
495 }
496 _ => parse_markdown_subagent(&content, source.clone())?,
497 };
498 spec.file_path = Some(path.to_path_buf());
499 Ok(spec)
500}
501
502fn load_cli_agents(value: &JsonValue) -> Result<Vec<SubagentSpec>> {
503 let Some(object) = value.as_object() else {
504 bail!("CLI subagent payload must be a JSON object");
505 };
506
507 let mut specs = Vec::with_capacity(object.len());
508 for (name, raw) in object {
509 let Some(config) = raw.as_object() else {
510 bail!("CLI subagent '{name}' must be an object");
511 };
512 let description = required_string(config, "description")
513 .with_context(|| format!("CLI subagent '{name}' is missing description"))?;
514 let prompt = config
515 .get("prompt")
516 .and_then(JsonValue::as_str)
517 .unwrap_or_default()
518 .to_string();
519 let tools = optional_string_list(config.get("tools"))?;
520 let disallowed_tools =
521 optional_string_list(config.get("disallowedTools"))?.unwrap_or_default();
522 let model = config
523 .get("model")
524 .and_then(JsonValue::as_str)
525 .map(ToString::to_string);
526 let color = config
527 .get("color")
528 .or_else(|| config.get("badgeColor"))
529 .or_else(|| config.get("badge_color"))
530 .and_then(JsonValue::as_str)
531 .map(ToString::to_string);
532 let reasoning_effort = config
533 .get("reasoning_effort")
534 .or_else(|| config.get("model_reasoning_effort"))
535 .or_else(|| config.get("effort"))
536 .and_then(JsonValue::as_str)
537 .map(ToString::to_string);
538 let permission_mode = config
539 .get("permissionMode")
540 .or_else(|| config.get("permission_mode"))
541 .and_then(JsonValue::as_str)
542 .map(parse_permission_mode)
543 .transpose()?;
544 let skills = optional_string_list(config.get("skills"))?.unwrap_or_default();
545 let mcp_servers = optional_mcp_servers(
546 config
547 .get("mcpServers")
548 .or_else(|| config.get("mcp_servers")),
549 )?;
550 let hooks = optional_hooks(config.get("hooks"))?;
551 let max_turns = config
552 .get("maxTurns")
553 .or_else(|| config.get("max_turns"))
554 .and_then(JsonValue::as_u64)
555 .map(|value| value as usize);
556 let background = config
557 .get("background")
558 .and_then(JsonValue::as_bool)
559 .unwrap_or(false);
560 let nickname_candidates =
561 optional_string_list(config.get("nickname_candidates"))?.unwrap_or_default();
562 let initial_prompt = config
563 .get("initialPrompt")
564 .or_else(|| config.get("initial_prompt"))
565 .and_then(JsonValue::as_str)
566 .map(ToString::to_string);
567 let memory = config
568 .get("memory")
569 .and_then(JsonValue::as_str)
570 .map(parse_memory_scope)
571 .transpose()?;
572 let isolation = config
573 .get("isolation")
574 .and_then(JsonValue::as_str)
575 .map(ToString::to_string);
576
577 specs.push(SubagentSpec {
578 name: name.clone(),
579 description,
580 prompt,
581 tools,
582 disallowed_tools,
583 model,
584 color,
585 reasoning_effort,
586 permission_mode,
587 skills,
588 mcp_servers,
589 hooks,
590 background,
591 max_turns,
592 nickname_candidates,
593 initial_prompt,
594 memory,
595 isolation,
596 aliases: Vec::new(),
597 source: SubagentSource::Cli,
598 file_path: None,
599 warnings: Vec::new(),
600 });
601 }
602
603 Ok(specs)
604}
605
606fn parse_markdown_subagent(content: &str, source: SubagentSource) -> Result<SubagentSpec> {
607 let trimmed = content.trim_start();
608 let Some(rest) = trimmed.strip_prefix("---") else {
609 bail!("markdown subagent is missing YAML frontmatter");
610 };
611 let Some(end_idx) = rest.find("\n---") else {
612 bail!("markdown subagent is missing closing frontmatter delimiter");
613 };
614 let frontmatter_text = rest[..end_idx].trim();
615 let prompt = rest[end_idx + 4..].trim().to_string();
616 let frontmatter = serde_yaml::from_str::<JsonValue>(frontmatter_text)
617 .context("failed to parse subagent YAML frontmatter")?;
618 let Some(object) = frontmatter.as_object() else {
619 bail!("subagent frontmatter must be a YAML mapping");
620 };
621
622 let mut spec = subagent_spec_from_json_map(object, prompt, source.clone())?;
623 if matches!(source, SubagentSource::Plugin { .. }) {
624 apply_plugin_restrictions(&mut spec);
625 }
626 Ok(spec)
627}
628
629fn parse_codex_toml_subagent(content: &str, source: SubagentSource) -> Result<SubagentSpec> {
630 let root = toml::from_str::<toml::Value>(content).context("failed to parse subagent TOML")?;
631 let Some(table) = root.as_table() else {
632 bail!("Codex subagent TOML must be a table");
633 };
634 let object = toml_table_to_json_object(table)?;
635 let prompt = object
636 .get("developer_instructions")
637 .or_else(|| object.get("instructions"))
638 .and_then(JsonValue::as_str)
639 .unwrap_or_default()
640 .to_string();
641
642 let spec = subagent_spec_from_json_map(&object, prompt, source)?;
643 if spec.description.trim().is_empty() {
644 bail!("Codex subagent TOML requires a description");
645 }
646 if spec.name.trim().is_empty() {
647 bail!("Codex subagent TOML requires a name");
648 }
649 Ok(spec)
650}
651
652fn subagent_spec_from_json_map(
653 object: &JsonMap<String, JsonValue>,
654 prompt: String,
655 source: SubagentSource,
656) -> Result<SubagentSpec> {
657 let name = required_string(object, "name")?;
658 let description = required_string(object, "description")?;
659 let tools = normalize_subagent_tool_list(optional_string_list(
660 object
661 .get("tools")
662 .or_else(|| object.get("allowed_tools"))
663 .or_else(|| object.get("enabled_tools")),
664 )?);
665 let disallowed_tools = normalize_subagent_tools(
666 optional_string_list(
667 object
668 .get("disallowedTools")
669 .or_else(|| object.get("disallowed_tools"))
670 .or_else(|| object.get("disabled_tools")),
671 )?
672 .unwrap_or_default(),
673 );
674 let model = object
675 .get("model")
676 .and_then(JsonValue::as_str)
677 .map(ToString::to_string);
678 let color = object
679 .get("color")
680 .or_else(|| object.get("badgeColor"))
681 .or_else(|| object.get("badge_color"))
682 .and_then(JsonValue::as_str)
683 .map(ToString::to_string);
684 let reasoning_effort = object
685 .get("reasoning_effort")
686 .or_else(|| object.get("model_reasoning_effort"))
687 .or_else(|| object.get("effort"))
688 .and_then(JsonValue::as_str)
689 .map(ToString::to_string);
690 let permission_mode = object
691 .get("permissionMode")
692 .or_else(|| object.get("permission_mode"))
693 .and_then(JsonValue::as_str)
694 .map(parse_permission_mode)
695 .transpose()?;
696 let skills = optional_string_list(object.get("skills"))?.unwrap_or_default();
697 let mcp_servers = optional_mcp_servers(
698 object
699 .get("mcpServers")
700 .or_else(|| object.get("mcp_servers")),
701 )?;
702 let hooks = optional_hooks(object.get("hooks"))?;
703 let background = object
704 .get("background")
705 .and_then(JsonValue::as_bool)
706 .unwrap_or(false);
707 let max_turns = object
708 .get("maxTurns")
709 .or_else(|| object.get("max_turns"))
710 .and_then(JsonValue::as_u64)
711 .map(|value| value as usize);
712 let nickname_candidates =
713 optional_string_list(object.get("nickname_candidates"))?.unwrap_or_default();
714 let initial_prompt = object
715 .get("initialPrompt")
716 .or_else(|| object.get("initial_prompt"))
717 .and_then(JsonValue::as_str)
718 .map(ToString::to_string);
719 let memory = object
720 .get("memory")
721 .and_then(JsonValue::as_str)
722 .map(parse_memory_scope)
723 .transpose()?;
724 let isolation = object
725 .get("isolation")
726 .and_then(JsonValue::as_str)
727 .map(ToString::to_string);
728
729 Ok(SubagentSpec {
730 name,
731 description,
732 prompt,
733 tools,
734 disallowed_tools,
735 model,
736 color,
737 reasoning_effort,
738 permission_mode,
739 skills,
740 mcp_servers,
741 hooks,
742 background,
743 max_turns,
744 nickname_candidates,
745 initial_prompt,
746 memory,
747 isolation,
748 aliases: Vec::new(),
749 source,
750 file_path: None,
751 warnings: Vec::new(),
752 })
753}
754
755fn normalize_subagent_tool_list(tools: Option<Vec<String>>) -> Option<Vec<String>> {
756 tools.map(normalize_subagent_tools)
757}
758
759fn normalize_subagent_tools(tools: Vec<String>) -> Vec<String> {
760 let mut normalized: Vec<String> = Vec::new();
761 for tool in tools {
762 let trimmed = tool.trim();
763 let mapped_names = normalize_subagent_tool_name(trimmed);
764 if mapped_names.is_empty() {
765 if !trimmed.is_empty()
766 && !normalized
767 .iter()
768 .any(|existing| existing.eq_ignore_ascii_case(trimmed))
769 {
770 normalized.push(trimmed.to_string());
771 }
772 continue;
773 }
774
775 for mapped in mapped_names {
776 if !normalized.iter().any(|existing| existing == mapped) {
777 normalized.push(mapped.to_string());
778 }
779 }
780 }
781 normalized
782}
783
784fn normalize_subagent_tool_name(tool: &str) -> &'static [&'static str] {
785 match tool.trim().to_ascii_lowercase().as_str() {
786 "read" => &[tools::READ_FILE],
787 "write" => &[tools::WRITE_FILE],
788 "edit" | "multiedit" | "multi_edit" | "multi-edit" => &[tools::EDIT_FILE],
789 "grep" | "grep_file" | "grepfile" => &[tools::UNIFIED_SEARCH],
790 "glob" | "list" | "list_files" | "listfiles" => &[tools::LIST_FILES],
791 "bash" | "shell" | "command" => &[tools::UNIFIED_EXEC],
792 "patch" | "applypatch" | "apply_patch" => &[tools::APPLY_PATCH],
793 "agent" | "task" => &[tools::SPAWN_AGENT],
794 "askuserquestion" | "ask_user_question" | "requestuserinput" | "request_user_input" => {
795 &[tools::REQUEST_USER_INPUT]
796 }
797 _ => &[],
798 }
799}
800
801fn is_mutating_tool_name(tool: &str) -> bool {
802 tool == "edit"
803 || tool == "write"
804 || tool == tools::UNIFIED_EXEC
805 || tool == tools::EDIT_FILE
806 || tool == tools::WRITE_FILE
807 || tool == tools::UNIFIED_FILE
808 || tool == tools::APPLY_PATCH
809 || tool == tools::CREATE_FILE
810 || tool == tools::DELETE_FILE
811 || tool == tools::MOVE_FILE
812 || tool == tools::COPY_FILE
813 || tool == tools::SEARCH_REPLACE
814}
815
816fn apply_plugin_restrictions(spec: &mut SubagentSpec) {
817 if spec.hooks.take().is_some() {
818 spec.warnings
819 .push("plugin subagent hooks are ignored for safety".to_string());
820 }
821 if !spec.mcp_servers.is_empty() {
822 spec.mcp_servers.clear();
823 spec.warnings
824 .push("plugin subagent mcp_servers are ignored for safety".to_string());
825 }
826 if spec.permission_mode.take().is_some() {
827 spec.warnings
828 .push("plugin subagent permission_mode is ignored for safety".to_string());
829 }
830}
831
832fn required_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
833 object
834 .get(key)
835 .and_then(JsonValue::as_str)
836 .map(str::trim)
837 .filter(|value| !value.is_empty())
838 .map(ToString::to_string)
839 .ok_or_else(|| anyhow!("missing required subagent field '{key}'"))
840}
841
842fn optional_string_list(value: Option<&JsonValue>) -> Result<Option<Vec<String>>> {
843 let Some(value) = value else {
844 return Ok(None);
845 };
846
847 match value {
848 JsonValue::Null => Ok(None),
849 JsonValue::String(text) => Ok(Some(
850 text.split(',')
851 .map(str::trim)
852 .filter(|item| !item.is_empty())
853 .map(ToString::to_string)
854 .collect(),
855 )),
856 JsonValue::Array(items) => Ok(Some(
857 items
858 .iter()
859 .filter_map(JsonValue::as_str)
860 .map(str::trim)
861 .filter(|item| !item.is_empty())
862 .map(ToString::to_string)
863 .collect(),
864 )),
865 JsonValue::Bool(enabled) => {
866 if *enabled {
867 Ok(Some(Vec::new()))
868 } else {
869 Ok(None)
870 }
871 }
872 _ => bail!("expected string or string array for subagent list field"),
873 }
874}
875
876fn optional_mcp_servers(value: Option<&JsonValue>) -> Result<Vec<SubagentMcpServer>> {
877 let Some(value) = value else {
878 return Ok(Vec::new());
879 };
880
881 match value {
882 JsonValue::Null => Ok(Vec::new()),
883 JsonValue::Array(entries) => entries
884 .iter()
885 .map(parse_mcp_server_value)
886 .collect::<Result<Vec<_>>>(),
887 JsonValue::Object(map) => {
888 let mut servers = Vec::with_capacity(map.len());
889 for (name, config) in map {
890 let mut inline = BTreeMap::new();
891 inline.insert(name.clone(), config.clone());
892 servers.push(SubagentMcpServer::Inline(inline));
893 }
894 Ok(servers)
895 }
896 _ => bail!("expected object or array for mcp_servers"),
897 }
898}
899
900fn parse_mcp_server_value(value: &JsonValue) -> Result<SubagentMcpServer> {
901 match value {
902 JsonValue::String(name) => Ok(SubagentMcpServer::Named(name.clone())),
903 JsonValue::Object(map) => Ok(SubagentMcpServer::Inline(
904 map.iter()
905 .map(|(key, value)| (key.clone(), value.clone()))
906 .collect(),
907 )),
908 _ => bail!("invalid mcp_servers entry"),
909 }
910}
911
912fn optional_hooks(value: Option<&JsonValue>) -> Result<Option<HooksConfig>> {
913 let Some(value) = value else {
914 return Ok(None);
915 };
916 if value.is_null() {
917 return Ok(None);
918 }
919
920 let object = value
921 .as_object()
922 .ok_or_else(|| anyhow!("subagent hooks must be an object"))?;
923
924 if object.contains_key("lifecycle") {
925 let hooks = serde_json::from_value::<HooksConfig>(value.clone())
926 .context("failed to parse VT Code lifecycle hooks")?;
927 return Ok(Some(hooks));
928 }
929
930 let mut config = HooksConfig::default();
931 for (event, raw_groups) in object {
932 let target = match event.as_str() {
933 "PreToolUse" | "pre_tool_use" => &mut config.lifecycle.pre_tool_use,
934 "PostToolUse" | "post_tool_use" => &mut config.lifecycle.post_tool_use,
935 "Stop" | "stop" => &mut config.lifecycle.task_completed,
936 "SubagentStart" | "subagent_start" => &mut config.lifecycle.subagent_start,
937 "SubagentStop" | "subagent_stop" => &mut config.lifecycle.subagent_stop,
938 _ => continue,
939 };
940 target.extend(parse_hook_groups(raw_groups)?);
941 }
942
943 Ok(Some(config))
944}
945
946fn parse_hook_groups(value: &JsonValue) -> Result<Vec<HookGroupConfig>> {
947 let groups = value
948 .as_array()
949 .ok_or_else(|| anyhow!("hook groups must be arrays"))?;
950 let mut parsed = Vec::with_capacity(groups.len());
951 for group in groups {
952 let Some(object) = group.as_object() else {
953 bail!("hook group must be an object");
954 };
955 let matcher = object
956 .get("matcher")
957 .and_then(JsonValue::as_str)
958 .map(ToString::to_string);
959 let hooks = object
960 .get("hooks")
961 .and_then(JsonValue::as_array)
962 .ok_or_else(|| anyhow!("hook group requires hooks array"))?
963 .iter()
964 .map(parse_hook_command)
965 .collect::<Result<Vec<_>>>()?;
966 parsed.push(HookGroupConfig { matcher, hooks });
967 }
968 Ok(parsed)
969}
970
971fn parse_hook_command(value: &JsonValue) -> Result<HookCommandConfig> {
972 let Some(object) = value.as_object() else {
973 bail!("hook command must be an object");
974 };
975 let command = object
976 .get("command")
977 .and_then(JsonValue::as_str)
978 .map(ToString::to_string)
979 .ok_or_else(|| anyhow!("hook command requires a command string"))?;
980 let timeout_seconds = object.get("timeout_seconds").and_then(JsonValue::as_u64);
981 Ok(HookCommandConfig {
982 kind: HookCommandKind::Command,
983 command,
984 timeout_seconds,
985 })
986}
987
988fn parse_permission_mode(value: &str) -> Result<PermissionMode> {
989 match value.trim().to_ascii_lowercase().as_str() {
990 "default" => Ok(PermissionMode::Default),
991 "acceptedits" | "accept_edits" | "accept-edits" => Ok(PermissionMode::AcceptEdits),
992 "dontask" | "dont_ask" | "dont-ask" => Ok(PermissionMode::DontAsk),
993 "bypasspermissions" | "bypass_permissions" | "bypass-permissions" => {
994 Ok(PermissionMode::BypassPermissions)
995 }
996 "plan" => Ok(PermissionMode::Plan),
997 "auto" => Ok(PermissionMode::Auto),
998 other => bail!("unsupported subagent permission mode '{other}'"),
999 }
1000}
1001
1002fn parse_memory_scope(value: &str) -> Result<SubagentMemoryScope> {
1003 match value.trim().to_ascii_lowercase().as_str() {
1004 "user" => Ok(SubagentMemoryScope::User),
1005 "project" => Ok(SubagentMemoryScope::Project),
1006 "local" => Ok(SubagentMemoryScope::Local),
1007 other => bail!("unsupported subagent memory scope '{other}'"),
1008 }
1009}
1010
1011fn toml_table_to_json_object(
1012 table: &toml::map::Map<String, toml::Value>,
1013) -> Result<JsonMap<String, JsonValue>> {
1014 let value = serde_json::to_value(table).context("failed to convert TOML table to JSON")?;
1015 value
1016 .as_object()
1017 .cloned()
1018 .ok_or_else(|| anyhow!("expected TOML table to convert into a JSON object"))
1019}
1020
1021const fn default_subagents_enabled() -> bool {
1022 true
1023}
1024
1025const fn default_subagents_max_concurrent() -> usize {
1026 3
1027}
1028
1029const fn default_subagents_max_depth() -> usize {
1030 1
1031}
1032
1033const fn default_subagents_default_timeout_seconds() -> u64 {
1034 300
1035}
1036
1037const fn default_subagents_auto_delegate_read_only() -> bool {
1038 true
1039}
1040
1041const fn default_background_subagents_enabled() -> bool {
1042 false
1043}
1044
1045const fn default_background_refresh_interval_ms() -> u64 {
1046 2_000
1047}
1048
1049const fn default_background_auto_restore() -> bool {
1050 false
1051}
1052
1053fn default_background_toggle_shortcut() -> String {
1054 "ctrl+b".to_string()
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::{
1060 BackgroundSubagentConfig, SubagentDiscoveryInput, SubagentRuntimeLimits, SubagentSource,
1061 builtin_subagents, discover_subagents, load_subagent_from_file,
1062 };
1063 use crate::constants::tools;
1064 use anyhow::Result;
1065 use std::fs;
1066 use tempfile::TempDir;
1067
1068 #[test]
1069 fn parses_claude_markdown_frontmatter() -> Result<()> {
1070 let temp = TempDir::new()?;
1071 let path = temp.path().join("reviewer.md");
1072 fs::write(
1073 &path,
1074 r#"---
1075name: reviewer
1076description: Review code
1077tools: [Read, Grep, Glob]
1078disallowedTools: [Write]
1079model: sonnet
1080color: blue
1081permissionMode: plan
1082skills: [rust]
1083memory: project
1084background: true
1085maxTurns: 7
1086nickname_candidates: [rev]
1087---
1088
1089Review the target changes."#,
1090 )?;
1091
1092 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1093 assert_eq!(spec.name, "reviewer");
1094 assert_eq!(spec.description, "Review code");
1095 assert_eq!(spec.model.as_deref(), Some("sonnet"));
1096 assert_eq!(spec.color.as_deref(), Some("blue"));
1097 assert_eq!(
1098 spec.tools,
1099 Some(vec![
1100 tools::READ_FILE.to_string(),
1101 tools::UNIFIED_SEARCH.to_string(),
1102 tools::LIST_FILES.to_string(),
1103 ])
1104 );
1105 assert_eq!(spec.disallowed_tools, vec![tools::WRITE_FILE.to_string()]);
1106 assert!(spec.background);
1107 assert_eq!(spec.max_turns, Some(7));
1108 assert_eq!(spec.prompt, "Review the target changes.");
1109 Ok(())
1110 }
1111
1112 #[test]
1113 fn normalizes_claude_tool_aliases_to_vtcode_tools() -> Result<()> {
1114 let temp = TempDir::new()?;
1115 let path = temp.path().join("debugger.md");
1116 fs::write(
1117 &path,
1118 r#"---
1119name: debugger
1120description: Debug agent
1121tools: [Read, Bash, Edit, Write, Glob, Grep]
1122disallowedTools: [Task]
1123---
1124Debug the issue."#,
1125 )?;
1126
1127 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1128 assert_eq!(
1129 spec.tools,
1130 Some(vec![
1131 tools::READ_FILE.to_string(),
1132 tools::UNIFIED_EXEC.to_string(),
1133 tools::EDIT_FILE.to_string(),
1134 tools::WRITE_FILE.to_string(),
1135 tools::LIST_FILES.to_string(),
1136 tools::UNIFIED_SEARCH.to_string(),
1137 ])
1138 );
1139 assert_eq!(spec.disallowed_tools, vec![tools::SPAWN_AGENT.to_string()]);
1140 assert!(!spec.is_read_only());
1141 Ok(())
1142 }
1143
1144 #[test]
1145 fn shell_only_agents_are_not_read_only() -> Result<()> {
1146 let temp = TempDir::new()?;
1147 let path = temp.path().join("shell.md");
1148 fs::write(
1149 &path,
1150 r#"---
1151name: shell
1152description: Shell-capable agent
1153tools: [Bash]
1154---
1155Run shell commands."#,
1156 )?;
1157
1158 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1159 assert_eq!(spec.tools, Some(vec![tools::UNIFIED_EXEC.to_string()]));
1160 assert!(!spec.is_read_only());
1161 Ok(())
1162 }
1163
1164 #[test]
1165 fn parses_codex_toml_definition() -> Result<()> {
1166 let temp = TempDir::new()?;
1167 let path = temp.path().join("worker.toml");
1168 fs::write(
1169 &path,
1170 r##"name = "worker"
1171description = "Write-capable implementation agent"
1172developer_instructions = "Implement the assigned change."
1173model = "gpt-5.4"
1174color = "#4f8fd8"
1175model_reasoning_effort = "high"
1176nickname_candidates = ["builder"]
1177"##,
1178 )?;
1179
1180 let spec = load_subagent_from_file(&path, SubagentSource::ProjectCodex)?;
1181 assert_eq!(spec.name, "worker");
1182 assert_eq!(spec.description, "Write-capable implementation agent");
1183 assert_eq!(spec.prompt, "Implement the assigned change.");
1184 assert_eq!(spec.model.as_deref(), Some("gpt-5.4"));
1185 assert_eq!(spec.color.as_deref(), Some("#4f8fd8"));
1186 assert_eq!(spec.reasoning_effort.as_deref(), Some("high"));
1187 assert_eq!(spec.nickname_candidates, vec!["builder".to_string()]);
1188 Ok(())
1189 }
1190
1191 #[test]
1192 fn precedence_prefers_project_vtcode_then_claude_then_codex_then_user() -> Result<()> {
1193 let temp = TempDir::new()?;
1194 fs::create_dir_all(temp.path().join(".codex/agents"))?;
1195 fs::create_dir_all(temp.path().join(".claude/agents"))?;
1196 fs::create_dir_all(temp.path().join(".vtcode/agents"))?;
1197
1198 fs::write(
1199 temp.path().join(".codex/agents/example.toml"),
1200 r#"name = "example"
1201description = "codex"
1202developer_instructions = "codex"
1203"#,
1204 )?;
1205 fs::write(
1206 temp.path().join(".claude/agents/example.md"),
1207 r#"---
1208name: example
1209description: claude
1210---
1211claude"#,
1212 )?;
1213 fs::write(
1214 temp.path().join(".vtcode/agents/example.md"),
1215 r#"---
1216name: example
1217description: vtcode
1218---
1219vtcode"#,
1220 )?;
1221
1222 let discovered =
1223 discover_subagents(&SubagentDiscoveryInput::new(temp.path().to_path_buf()))?;
1224 let effective = discovered
1225 .effective
1226 .into_iter()
1227 .find(|spec| spec.name == "example")
1228 .expect("example effective");
1229 assert_eq!(effective.description, "vtcode");
1230 assert_eq!(effective.source, SubagentSource::ProjectVtcode);
1231 Ok(())
1232 }
1233
1234 #[test]
1235 fn plugin_restrictions_strip_unsafe_overrides() -> Result<()> {
1236 let temp = TempDir::new()?;
1237 let path = temp.path().join("plugin-agent.md");
1238 fs::write(
1239 &path,
1240 r#"---
1241name: plugin-agent
1242description: Plugin agent
1243permissionMode: bypassPermissions
1244mcpServers:
1245 - github
1246hooks:
1247 PreToolUse:
1248 - matcher: Bash
1249 hooks:
1250 - type: command
1251 command: ./check.sh
1252---
1253Plugin prompt"#,
1254 )?;
1255
1256 let spec = load_subagent_from_file(
1257 &path,
1258 SubagentSource::Plugin {
1259 plugin: "demo".to_string(),
1260 },
1261 )?;
1262 assert!(spec.permission_mode.is_none());
1263 assert!(spec.mcp_servers.is_empty());
1264 assert!(spec.hooks.is_none());
1265 assert_eq!(spec.warnings.len(), 3);
1266 Ok(())
1267 }
1268
1269 #[test]
1270 fn parses_subagent_lifecycle_hooks_from_frontmatter() -> Result<()> {
1271 let temp = TempDir::new()?;
1272 let path = temp.path().join("hooks.md");
1273 fs::write(
1274 &path,
1275 r#"---
1276name: hook-agent
1277description: Hooked agent
1278hooks:
1279 SubagentStart:
1280 - matcher: worker
1281 hooks:
1282 - type: command
1283 command: echo start
1284 SubagentStop:
1285 - hooks:
1286 - type: command
1287 command: echo stop
1288---
1289Hook prompt"#,
1290 )?;
1291
1292 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1293 let hooks = spec.hooks.expect("hooks");
1294 assert_eq!(hooks.lifecycle.subagent_start.len(), 1);
1295 assert_eq!(hooks.lifecycle.subagent_stop.len(), 1);
1296 assert_eq!(
1297 hooks.lifecycle.subagent_start[0].matcher.as_deref(),
1298 Some("worker")
1299 );
1300 Ok(())
1301 }
1302
1303 #[test]
1304 fn builtin_aliases_cover_compat_names() {
1305 let builtins = builtin_subagents();
1306 let explorer = builtins
1307 .iter()
1308 .find(|spec| spec.name == "explorer")
1309 .expect("explorer builtin");
1310 let worker = builtins
1311 .iter()
1312 .find(|spec| spec.name == "worker")
1313 .expect("worker builtin");
1314 assert!(explorer.matches_name("explore"));
1315 assert!(worker.matches_name("general"));
1316 assert!(worker.matches_name("general-purpose"));
1317 }
1318
1319 #[test]
1320 fn background_subagent_runtime_defaults_match_documented_shortcuts() {
1321 let config = BackgroundSubagentConfig::default();
1322 assert!(!config.enabled);
1323 assert_eq!(config.default_agent, None);
1324 assert_eq!(config.refresh_interval_ms, 2_000);
1325 assert!(!config.auto_restore);
1326 assert_eq!(config.toggle_shortcut, "ctrl+b");
1327 }
1328
1329 #[test]
1330 fn subagent_runtime_limits_embed_background_defaults() {
1331 let limits = SubagentRuntimeLimits::default();
1332 assert_eq!(limits.max_concurrent, 3);
1333 assert_eq!(limits.background.default_agent, None);
1334 assert_eq!(limits.background.toggle_shortcut, "ctrl+b");
1335 }
1336
1337 #[test]
1338 fn background_subagent_runtime_deserializes_explicit_default_agent() {
1339 let config: BackgroundSubagentConfig = toml::from_str(
1340 r#"
1341enabled = true
1342default_agent = "rust-engineer"
1343refresh_interval_ms = 1500
1344auto_restore = true
1345toggle_shortcut = "ctrl+b"
1346"#,
1347 )
1348 .expect("background config");
1349
1350 assert!(config.enabled);
1351 assert_eq!(config.default_agent.as_deref(), Some("rust-engineer"));
1352 assert_eq!(config.refresh_interval_ms, 1_500);
1353 assert!(config.auto_restore);
1354 assert_eq!(config.toggle_shortcut, "ctrl+b");
1355 }
1356}