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