Skip to main content

vtcode_config/
subagents.rs

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}