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_saphyr::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 "PermissionRequest" | "permission_request" => &mut config.lifecycle.permission_request,
936 "Stop" | "stop" => &mut config.lifecycle.stop,
937 "SubagentStart" | "subagent_start" => &mut config.lifecycle.subagent_start,
938 "SubagentStop" | "subagent_stop" => &mut config.lifecycle.subagent_stop,
939 _ => continue,
940 };
941 target.extend(parse_hook_groups(raw_groups)?);
942 }
943
944 Ok(Some(config))
945}
946
947fn parse_hook_groups(value: &JsonValue) -> Result<Vec<HookGroupConfig>> {
948 let groups = value
949 .as_array()
950 .ok_or_else(|| anyhow!("hook groups must be arrays"))?;
951 let mut parsed = Vec::with_capacity(groups.len());
952 for group in groups {
953 let Some(object) = group.as_object() else {
954 bail!("hook group must be an object");
955 };
956 let matcher = object
957 .get("matcher")
958 .and_then(JsonValue::as_str)
959 .map(ToString::to_string);
960 let hooks = object
961 .get("hooks")
962 .and_then(JsonValue::as_array)
963 .ok_or_else(|| anyhow!("hook group requires hooks array"))?
964 .iter()
965 .map(parse_hook_command)
966 .collect::<Result<Vec<_>>>()?;
967 parsed.push(HookGroupConfig { matcher, hooks });
968 }
969 Ok(parsed)
970}
971
972fn parse_hook_command(value: &JsonValue) -> Result<HookCommandConfig> {
973 let Some(object) = value.as_object() else {
974 bail!("hook command must be an object");
975 };
976 let command = object
977 .get("command")
978 .and_then(JsonValue::as_str)
979 .map(ToString::to_string)
980 .ok_or_else(|| anyhow!("hook command requires a command string"))?;
981 let timeout_seconds = object.get("timeout_seconds").and_then(JsonValue::as_u64);
982 Ok(HookCommandConfig {
983 kind: HookCommandKind::Command,
984 command,
985 timeout_seconds,
986 })
987}
988
989fn parse_permission_mode(value: &str) -> Result<PermissionMode> {
990 match value.trim().to_ascii_lowercase().as_str() {
991 "default" => Ok(PermissionMode::Default),
992 "acceptedits" | "accept_edits" | "accept-edits" => Ok(PermissionMode::AcceptEdits),
993 "dontask" | "dont_ask" | "dont-ask" => Ok(PermissionMode::DontAsk),
994 "bypasspermissions" | "bypass_permissions" | "bypass-permissions" => {
995 Ok(PermissionMode::BypassPermissions)
996 }
997 "plan" => Ok(PermissionMode::Plan),
998 "auto" => Ok(PermissionMode::Auto),
999 other => bail!("unsupported subagent permission mode '{other}'"),
1000 }
1001}
1002
1003fn parse_memory_scope(value: &str) -> Result<SubagentMemoryScope> {
1004 match value.trim().to_ascii_lowercase().as_str() {
1005 "user" => Ok(SubagentMemoryScope::User),
1006 "project" => Ok(SubagentMemoryScope::Project),
1007 "local" => Ok(SubagentMemoryScope::Local),
1008 other => bail!("unsupported subagent memory scope '{other}'"),
1009 }
1010}
1011
1012fn toml_table_to_json_object(
1013 table: &toml::map::Map<String, toml::Value>,
1014) -> Result<JsonMap<String, JsonValue>> {
1015 let value = serde_json::to_value(table).context("failed to convert TOML table to JSON")?;
1016 value
1017 .as_object()
1018 .cloned()
1019 .ok_or_else(|| anyhow!("expected TOML table to convert into a JSON object"))
1020}
1021
1022const fn default_subagents_enabled() -> bool {
1023 true
1024}
1025
1026const fn default_subagents_max_concurrent() -> usize {
1027 3
1028}
1029
1030const fn default_subagents_max_depth() -> usize {
1031 1
1032}
1033
1034const fn default_subagents_default_timeout_seconds() -> u64 {
1035 300
1036}
1037
1038const fn default_subagents_auto_delegate_read_only() -> bool {
1039 true
1040}
1041
1042const fn default_background_subagents_enabled() -> bool {
1043 false
1044}
1045
1046const fn default_background_refresh_interval_ms() -> u64 {
1047 2_000
1048}
1049
1050const fn default_background_auto_restore() -> bool {
1051 false
1052}
1053
1054fn default_background_toggle_shortcut() -> String {
1055 "ctrl+b".to_string()
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::{
1061 BackgroundSubagentConfig, SubagentDiscoveryInput, SubagentRuntimeLimits, SubagentSource,
1062 builtin_subagents, discover_subagents, load_subagent_from_file,
1063 };
1064 use crate::constants::tools;
1065 use anyhow::Result;
1066 use std::fs;
1067 use tempfile::TempDir;
1068
1069 #[test]
1070 fn parses_claude_markdown_frontmatter() -> Result<()> {
1071 let temp = TempDir::new()?;
1072 let path = temp.path().join("reviewer.md");
1073 fs::write(
1074 &path,
1075 r#"---
1076name: reviewer
1077description: Review code
1078tools: [Read, Grep, Glob]
1079disallowedTools: [Write]
1080model: sonnet
1081color: blue
1082permissionMode: plan
1083skills: [rust]
1084memory: project
1085background: true
1086maxTurns: 7
1087nickname_candidates: [rev]
1088---
1089
1090Review the target changes."#,
1091 )?;
1092
1093 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1094 assert_eq!(spec.name, "reviewer");
1095 assert_eq!(spec.description, "Review code");
1096 assert_eq!(spec.model.as_deref(), Some("sonnet"));
1097 assert_eq!(spec.color.as_deref(), Some("blue"));
1098 assert_eq!(
1099 spec.tools,
1100 Some(vec![
1101 tools::READ_FILE.to_string(),
1102 tools::UNIFIED_SEARCH.to_string(),
1103 tools::LIST_FILES.to_string(),
1104 ])
1105 );
1106 assert_eq!(spec.disallowed_tools, vec![tools::WRITE_FILE.to_string()]);
1107 assert!(spec.background);
1108 assert_eq!(spec.max_turns, Some(7));
1109 assert_eq!(spec.prompt, "Review the target changes.");
1110 Ok(())
1111 }
1112
1113 #[test]
1114 fn normalizes_claude_tool_aliases_to_vtcode_tools() -> Result<()> {
1115 let temp = TempDir::new()?;
1116 let path = temp.path().join("debugger.md");
1117 fs::write(
1118 &path,
1119 r#"---
1120name: debugger
1121description: Debug agent
1122tools: [Read, Bash, Edit, Write, Glob, Grep]
1123disallowedTools: [Task]
1124---
1125Debug the issue."#,
1126 )?;
1127
1128 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1129 assert_eq!(
1130 spec.tools,
1131 Some(vec![
1132 tools::READ_FILE.to_string(),
1133 tools::UNIFIED_EXEC.to_string(),
1134 tools::EDIT_FILE.to_string(),
1135 tools::WRITE_FILE.to_string(),
1136 tools::LIST_FILES.to_string(),
1137 tools::UNIFIED_SEARCH.to_string(),
1138 ])
1139 );
1140 assert_eq!(spec.disallowed_tools, vec![tools::SPAWN_AGENT.to_string()]);
1141 assert!(!spec.is_read_only());
1142 Ok(())
1143 }
1144
1145 #[test]
1146 fn shell_only_agents_are_not_read_only() -> Result<()> {
1147 let temp = TempDir::new()?;
1148 let path = temp.path().join("shell.md");
1149 fs::write(
1150 &path,
1151 r#"---
1152name: shell
1153description: Shell-capable agent
1154tools: [Bash]
1155---
1156Run shell commands."#,
1157 )?;
1158
1159 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1160 assert_eq!(spec.tools, Some(vec![tools::UNIFIED_EXEC.to_string()]));
1161 assert!(!spec.is_read_only());
1162 Ok(())
1163 }
1164
1165 #[test]
1166 fn parses_codex_toml_definition() -> Result<()> {
1167 let temp = TempDir::new()?;
1168 let path = temp.path().join("worker.toml");
1169 fs::write(
1170 &path,
1171 r##"name = "worker"
1172description = "Write-capable implementation agent"
1173developer_instructions = "Implement the assigned change."
1174model = "gpt-5.4"
1175color = "#4f8fd8"
1176model_reasoning_effort = "high"
1177nickname_candidates = ["builder"]
1178"##,
1179 )?;
1180
1181 let spec = load_subagent_from_file(&path, SubagentSource::ProjectCodex)?;
1182 assert_eq!(spec.name, "worker");
1183 assert_eq!(spec.description, "Write-capable implementation agent");
1184 assert_eq!(spec.prompt, "Implement the assigned change.");
1185 assert_eq!(spec.model.as_deref(), Some("gpt-5.4"));
1186 assert_eq!(spec.color.as_deref(), Some("#4f8fd8"));
1187 assert_eq!(spec.reasoning_effort.as_deref(), Some("high"));
1188 assert_eq!(spec.nickname_candidates, vec!["builder".to_string()]);
1189 Ok(())
1190 }
1191
1192 #[test]
1193 fn precedence_prefers_project_vtcode_then_claude_then_codex_then_user() -> Result<()> {
1194 let temp = TempDir::new()?;
1195 fs::create_dir_all(temp.path().join(".codex/agents"))?;
1196 fs::create_dir_all(temp.path().join(".claude/agents"))?;
1197 fs::create_dir_all(temp.path().join(".vtcode/agents"))?;
1198
1199 fs::write(
1200 temp.path().join(".codex/agents/example.toml"),
1201 r#"name = "example"
1202description = "codex"
1203developer_instructions = "codex"
1204"#,
1205 )?;
1206 fs::write(
1207 temp.path().join(".claude/agents/example.md"),
1208 r#"---
1209name: example
1210description: claude
1211---
1212claude"#,
1213 )?;
1214 fs::write(
1215 temp.path().join(".vtcode/agents/example.md"),
1216 r#"---
1217name: example
1218description: vtcode
1219---
1220vtcode"#,
1221 )?;
1222
1223 let discovered =
1224 discover_subagents(&SubagentDiscoveryInput::new(temp.path().to_path_buf()))?;
1225 let effective = discovered
1226 .effective
1227 .into_iter()
1228 .find(|spec| spec.name == "example")
1229 .expect("example effective");
1230 assert_eq!(effective.description, "vtcode");
1231 assert_eq!(effective.source, SubagentSource::ProjectVtcode);
1232 Ok(())
1233 }
1234
1235 #[test]
1236 fn plugin_restrictions_strip_unsafe_overrides() -> Result<()> {
1237 let temp = TempDir::new()?;
1238 let path = temp.path().join("plugin-agent.md");
1239 fs::write(
1240 &path,
1241 r#"---
1242name: plugin-agent
1243description: Plugin agent
1244permissionMode: bypassPermissions
1245mcpServers:
1246 - github
1247hooks:
1248 PreToolUse:
1249 - matcher: Bash
1250 hooks:
1251 - type: command
1252 command: ./check.sh
1253---
1254Plugin prompt"#,
1255 )?;
1256
1257 let spec = load_subagent_from_file(
1258 &path,
1259 SubagentSource::Plugin {
1260 plugin: "demo".to_string(),
1261 },
1262 )?;
1263 assert!(spec.permission_mode.is_none());
1264 assert!(spec.mcp_servers.is_empty());
1265 assert!(spec.hooks.is_none());
1266 assert_eq!(spec.warnings.len(), 3);
1267 Ok(())
1268 }
1269
1270 #[test]
1271 fn parses_subagent_lifecycle_hooks_from_frontmatter() -> Result<()> {
1272 let temp = TempDir::new()?;
1273 let path = temp.path().join("hooks.md");
1274 fs::write(
1275 &path,
1276 r#"---
1277name: hook-agent
1278description: Hooked agent
1279hooks:
1280 SubagentStart:
1281 - matcher: worker
1282 hooks:
1283 - type: command
1284 command: echo start
1285 SubagentStop:
1286 - hooks:
1287 - type: command
1288 command: echo stop
1289---
1290Hook prompt"#,
1291 )?;
1292
1293 let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1294 let hooks = spec.hooks.expect("hooks");
1295 assert_eq!(hooks.lifecycle.subagent_start.len(), 1);
1296 assert_eq!(hooks.lifecycle.subagent_stop.len(), 1);
1297 assert_eq!(
1298 hooks.lifecycle.subagent_start[0].matcher.as_deref(),
1299 Some("worker")
1300 );
1301 Ok(())
1302 }
1303
1304 #[test]
1305 fn builtin_aliases_cover_compat_names() {
1306 let builtins = builtin_subagents();
1307 let explorer = builtins
1308 .iter()
1309 .find(|spec| spec.name == "explorer")
1310 .expect("explorer builtin");
1311 let worker = builtins
1312 .iter()
1313 .find(|spec| spec.name == "worker")
1314 .expect("worker builtin");
1315 assert!(explorer.matches_name("explore"));
1316 assert!(worker.matches_name("general"));
1317 assert!(worker.matches_name("general-purpose"));
1318 }
1319
1320 #[test]
1321 fn background_subagent_runtime_defaults_match_documented_shortcuts() {
1322 let config = BackgroundSubagentConfig::default();
1323 assert!(!config.enabled);
1324 assert_eq!(config.default_agent, None);
1325 assert_eq!(config.refresh_interval_ms, 2_000);
1326 assert!(!config.auto_restore);
1327 assert_eq!(config.toggle_shortcut, "ctrl+b");
1328 }
1329
1330 #[test]
1331 fn subagent_runtime_limits_embed_background_defaults() {
1332 let limits = SubagentRuntimeLimits::default();
1333 assert_eq!(limits.max_concurrent, 3);
1334 assert_eq!(limits.background.default_agent, None);
1335 assert_eq!(limits.background.toggle_shortcut, "ctrl+b");
1336 }
1337
1338 #[test]
1339 fn background_subagent_runtime_deserializes_explicit_default_agent() {
1340 let config: BackgroundSubagentConfig = toml::from_str(
1341 r#"
1342enabled = true
1343default_agent = "rust-engineer"
1344refresh_interval_ms = 1500
1345auto_restore = true
1346toggle_shortcut = "ctrl+b"
1347"#,
1348 )
1349 .expect("background config");
1350
1351 assert!(config.enabled);
1352 assert_eq!(config.default_agent.as_deref(), Some("rust-engineer"));
1353 assert_eq!(config.refresh_interval_ms, 1_500);
1354 assert!(config.auto_restore);
1355 assert_eq!(config.toggle_shortcut, "ctrl+b");
1356 }
1357}