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_yaml::from_str::<JsonValue>(frontmatter_text)
617        .context("failed to parse subagent YAML frontmatter")?;
618    let Some(object) = frontmatter.as_object() else {
619        bail!("subagent frontmatter must be a YAML mapping");
620    };
621
622    let mut spec = subagent_spec_from_json_map(object, prompt, source.clone())?;
623    if matches!(source, SubagentSource::Plugin { .. }) {
624        apply_plugin_restrictions(&mut spec);
625    }
626    Ok(spec)
627}
628
629fn parse_codex_toml_subagent(content: &str, source: SubagentSource) -> Result<SubagentSpec> {
630    let root = toml::from_str::<toml::Value>(content).context("failed to parse subagent TOML")?;
631    let Some(table) = root.as_table() else {
632        bail!("Codex subagent TOML must be a table");
633    };
634    let object = toml_table_to_json_object(table)?;
635    let prompt = object
636        .get("developer_instructions")
637        .or_else(|| object.get("instructions"))
638        .and_then(JsonValue::as_str)
639        .unwrap_or_default()
640        .to_string();
641
642    let spec = subagent_spec_from_json_map(&object, prompt, source)?;
643    if spec.description.trim().is_empty() {
644        bail!("Codex subagent TOML requires a description");
645    }
646    if spec.name.trim().is_empty() {
647        bail!("Codex subagent TOML requires a name");
648    }
649    Ok(spec)
650}
651
652fn subagent_spec_from_json_map(
653    object: &JsonMap<String, JsonValue>,
654    prompt: String,
655    source: SubagentSource,
656) -> Result<SubagentSpec> {
657    let name = required_string(object, "name")?;
658    let description = required_string(object, "description")?;
659    let tools = normalize_subagent_tool_list(optional_string_list(
660        object
661            .get("tools")
662            .or_else(|| object.get("allowed_tools"))
663            .or_else(|| object.get("enabled_tools")),
664    )?);
665    let disallowed_tools = normalize_subagent_tools(
666        optional_string_list(
667            object
668                .get("disallowedTools")
669                .or_else(|| object.get("disallowed_tools"))
670                .or_else(|| object.get("disabled_tools")),
671        )?
672        .unwrap_or_default(),
673    );
674    let model = object
675        .get("model")
676        .and_then(JsonValue::as_str)
677        .map(ToString::to_string);
678    let color = object
679        .get("color")
680        .or_else(|| object.get("badgeColor"))
681        .or_else(|| object.get("badge_color"))
682        .and_then(JsonValue::as_str)
683        .map(ToString::to_string);
684    let reasoning_effort = object
685        .get("reasoning_effort")
686        .or_else(|| object.get("model_reasoning_effort"))
687        .or_else(|| object.get("effort"))
688        .and_then(JsonValue::as_str)
689        .map(ToString::to_string);
690    let permission_mode = object
691        .get("permissionMode")
692        .or_else(|| object.get("permission_mode"))
693        .and_then(JsonValue::as_str)
694        .map(parse_permission_mode)
695        .transpose()?;
696    let skills = optional_string_list(object.get("skills"))?.unwrap_or_default();
697    let mcp_servers = optional_mcp_servers(
698        object
699            .get("mcpServers")
700            .or_else(|| object.get("mcp_servers")),
701    )?;
702    let hooks = optional_hooks(object.get("hooks"))?;
703    let background = object
704        .get("background")
705        .and_then(JsonValue::as_bool)
706        .unwrap_or(false);
707    let max_turns = object
708        .get("maxTurns")
709        .or_else(|| object.get("max_turns"))
710        .and_then(JsonValue::as_u64)
711        .map(|value| value as usize);
712    let nickname_candidates =
713        optional_string_list(object.get("nickname_candidates"))?.unwrap_or_default();
714    let initial_prompt = object
715        .get("initialPrompt")
716        .or_else(|| object.get("initial_prompt"))
717        .and_then(JsonValue::as_str)
718        .map(ToString::to_string);
719    let memory = object
720        .get("memory")
721        .and_then(JsonValue::as_str)
722        .map(parse_memory_scope)
723        .transpose()?;
724    let isolation = object
725        .get("isolation")
726        .and_then(JsonValue::as_str)
727        .map(ToString::to_string);
728
729    Ok(SubagentSpec {
730        name,
731        description,
732        prompt,
733        tools,
734        disallowed_tools,
735        model,
736        color,
737        reasoning_effort,
738        permission_mode,
739        skills,
740        mcp_servers,
741        hooks,
742        background,
743        max_turns,
744        nickname_candidates,
745        initial_prompt,
746        memory,
747        isolation,
748        aliases: Vec::new(),
749        source,
750        file_path: None,
751        warnings: Vec::new(),
752    })
753}
754
755fn normalize_subagent_tool_list(tools: Option<Vec<String>>) -> Option<Vec<String>> {
756    tools.map(normalize_subagent_tools)
757}
758
759fn normalize_subagent_tools(tools: Vec<String>) -> Vec<String> {
760    let mut normalized: Vec<String> = Vec::new();
761    for tool in tools {
762        let trimmed = tool.trim();
763        let mapped_names = normalize_subagent_tool_name(trimmed);
764        if mapped_names.is_empty() {
765            if !trimmed.is_empty()
766                && !normalized
767                    .iter()
768                    .any(|existing| existing.eq_ignore_ascii_case(trimmed))
769            {
770                normalized.push(trimmed.to_string());
771            }
772            continue;
773        }
774
775        for mapped in mapped_names {
776            if !normalized.iter().any(|existing| existing == mapped) {
777                normalized.push(mapped.to_string());
778            }
779        }
780    }
781    normalized
782}
783
784fn normalize_subagent_tool_name(tool: &str) -> &'static [&'static str] {
785    match tool.trim().to_ascii_lowercase().as_str() {
786        "read" => &[tools::READ_FILE],
787        "write" => &[tools::WRITE_FILE],
788        "edit" | "multiedit" | "multi_edit" | "multi-edit" => &[tools::EDIT_FILE],
789        "grep" | "grep_file" | "grepfile" => &[tools::UNIFIED_SEARCH],
790        "glob" | "list" | "list_files" | "listfiles" => &[tools::LIST_FILES],
791        "bash" | "shell" | "command" => &[tools::UNIFIED_EXEC],
792        "patch" | "applypatch" | "apply_patch" => &[tools::APPLY_PATCH],
793        "agent" | "task" => &[tools::SPAWN_AGENT],
794        "askuserquestion" | "ask_user_question" | "requestuserinput" | "request_user_input" => {
795            &[tools::REQUEST_USER_INPUT]
796        }
797        _ => &[],
798    }
799}
800
801fn is_mutating_tool_name(tool: &str) -> bool {
802    tool == "edit"
803        || tool == "write"
804        || tool == tools::UNIFIED_EXEC
805        || tool == tools::EDIT_FILE
806        || tool == tools::WRITE_FILE
807        || tool == tools::UNIFIED_FILE
808        || tool == tools::APPLY_PATCH
809        || tool == tools::CREATE_FILE
810        || tool == tools::DELETE_FILE
811        || tool == tools::MOVE_FILE
812        || tool == tools::COPY_FILE
813        || tool == tools::SEARCH_REPLACE
814}
815
816fn apply_plugin_restrictions(spec: &mut SubagentSpec) {
817    if spec.hooks.take().is_some() {
818        spec.warnings
819            .push("plugin subagent hooks are ignored for safety".to_string());
820    }
821    if !spec.mcp_servers.is_empty() {
822        spec.mcp_servers.clear();
823        spec.warnings
824            .push("plugin subagent mcp_servers are ignored for safety".to_string());
825    }
826    if spec.permission_mode.take().is_some() {
827        spec.warnings
828            .push("plugin subagent permission_mode is ignored for safety".to_string());
829    }
830}
831
832fn required_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
833    object
834        .get(key)
835        .and_then(JsonValue::as_str)
836        .map(str::trim)
837        .filter(|value| !value.is_empty())
838        .map(ToString::to_string)
839        .ok_or_else(|| anyhow!("missing required subagent field '{key}'"))
840}
841
842fn optional_string_list(value: Option<&JsonValue>) -> Result<Option<Vec<String>>> {
843    let Some(value) = value else {
844        return Ok(None);
845    };
846
847    match value {
848        JsonValue::Null => Ok(None),
849        JsonValue::String(text) => Ok(Some(
850            text.split(',')
851                .map(str::trim)
852                .filter(|item| !item.is_empty())
853                .map(ToString::to_string)
854                .collect(),
855        )),
856        JsonValue::Array(items) => Ok(Some(
857            items
858                .iter()
859                .filter_map(JsonValue::as_str)
860                .map(str::trim)
861                .filter(|item| !item.is_empty())
862                .map(ToString::to_string)
863                .collect(),
864        )),
865        JsonValue::Bool(enabled) => {
866            if *enabled {
867                Ok(Some(Vec::new()))
868            } else {
869                Ok(None)
870            }
871        }
872        _ => bail!("expected string or string array for subagent list field"),
873    }
874}
875
876fn optional_mcp_servers(value: Option<&JsonValue>) -> Result<Vec<SubagentMcpServer>> {
877    let Some(value) = value else {
878        return Ok(Vec::new());
879    };
880
881    match value {
882        JsonValue::Null => Ok(Vec::new()),
883        JsonValue::Array(entries) => entries
884            .iter()
885            .map(parse_mcp_server_value)
886            .collect::<Result<Vec<_>>>(),
887        JsonValue::Object(map) => {
888            let mut servers = Vec::with_capacity(map.len());
889            for (name, config) in map {
890                let mut inline = BTreeMap::new();
891                inline.insert(name.clone(), config.clone());
892                servers.push(SubagentMcpServer::Inline(inline));
893            }
894            Ok(servers)
895        }
896        _ => bail!("expected object or array for mcp_servers"),
897    }
898}
899
900fn parse_mcp_server_value(value: &JsonValue) -> Result<SubagentMcpServer> {
901    match value {
902        JsonValue::String(name) => Ok(SubagentMcpServer::Named(name.clone())),
903        JsonValue::Object(map) => Ok(SubagentMcpServer::Inline(
904            map.iter()
905                .map(|(key, value)| (key.clone(), value.clone()))
906                .collect(),
907        )),
908        _ => bail!("invalid mcp_servers entry"),
909    }
910}
911
912fn optional_hooks(value: Option<&JsonValue>) -> Result<Option<HooksConfig>> {
913    let Some(value) = value else {
914        return Ok(None);
915    };
916    if value.is_null() {
917        return Ok(None);
918    }
919
920    let object = value
921        .as_object()
922        .ok_or_else(|| anyhow!("subagent hooks must be an object"))?;
923
924    if object.contains_key("lifecycle") {
925        let hooks = serde_json::from_value::<HooksConfig>(value.clone())
926            .context("failed to parse VT Code lifecycle hooks")?;
927        return Ok(Some(hooks));
928    }
929
930    let mut config = HooksConfig::default();
931    for (event, raw_groups) in object {
932        let target = match event.as_str() {
933            "PreToolUse" | "pre_tool_use" => &mut config.lifecycle.pre_tool_use,
934            "PostToolUse" | "post_tool_use" => &mut config.lifecycle.post_tool_use,
935            "Stop" | "stop" => &mut config.lifecycle.task_completed,
936            "SubagentStart" | "subagent_start" => &mut config.lifecycle.subagent_start,
937            "SubagentStop" | "subagent_stop" => &mut config.lifecycle.subagent_stop,
938            _ => continue,
939        };
940        target.extend(parse_hook_groups(raw_groups)?);
941    }
942
943    Ok(Some(config))
944}
945
946fn parse_hook_groups(value: &JsonValue) -> Result<Vec<HookGroupConfig>> {
947    let groups = value
948        .as_array()
949        .ok_or_else(|| anyhow!("hook groups must be arrays"))?;
950    let mut parsed = Vec::with_capacity(groups.len());
951    for group in groups {
952        let Some(object) = group.as_object() else {
953            bail!("hook group must be an object");
954        };
955        let matcher = object
956            .get("matcher")
957            .and_then(JsonValue::as_str)
958            .map(ToString::to_string);
959        let hooks = object
960            .get("hooks")
961            .and_then(JsonValue::as_array)
962            .ok_or_else(|| anyhow!("hook group requires hooks array"))?
963            .iter()
964            .map(parse_hook_command)
965            .collect::<Result<Vec<_>>>()?;
966        parsed.push(HookGroupConfig { matcher, hooks });
967    }
968    Ok(parsed)
969}
970
971fn parse_hook_command(value: &JsonValue) -> Result<HookCommandConfig> {
972    let Some(object) = value.as_object() else {
973        bail!("hook command must be an object");
974    };
975    let command = object
976        .get("command")
977        .and_then(JsonValue::as_str)
978        .map(ToString::to_string)
979        .ok_or_else(|| anyhow!("hook command requires a command string"))?;
980    let timeout_seconds = object.get("timeout_seconds").and_then(JsonValue::as_u64);
981    Ok(HookCommandConfig {
982        kind: HookCommandKind::Command,
983        command,
984        timeout_seconds,
985    })
986}
987
988fn parse_permission_mode(value: &str) -> Result<PermissionMode> {
989    match value.trim().to_ascii_lowercase().as_str() {
990        "default" => Ok(PermissionMode::Default),
991        "acceptedits" | "accept_edits" | "accept-edits" => Ok(PermissionMode::AcceptEdits),
992        "dontask" | "dont_ask" | "dont-ask" => Ok(PermissionMode::DontAsk),
993        "bypasspermissions" | "bypass_permissions" | "bypass-permissions" => {
994            Ok(PermissionMode::BypassPermissions)
995        }
996        "plan" => Ok(PermissionMode::Plan),
997        "auto" => Ok(PermissionMode::Auto),
998        other => bail!("unsupported subagent permission mode '{other}'"),
999    }
1000}
1001
1002fn parse_memory_scope(value: &str) -> Result<SubagentMemoryScope> {
1003    match value.trim().to_ascii_lowercase().as_str() {
1004        "user" => Ok(SubagentMemoryScope::User),
1005        "project" => Ok(SubagentMemoryScope::Project),
1006        "local" => Ok(SubagentMemoryScope::Local),
1007        other => bail!("unsupported subagent memory scope '{other}'"),
1008    }
1009}
1010
1011fn toml_table_to_json_object(
1012    table: &toml::map::Map<String, toml::Value>,
1013) -> Result<JsonMap<String, JsonValue>> {
1014    let value = serde_json::to_value(table).context("failed to convert TOML table to JSON")?;
1015    value
1016        .as_object()
1017        .cloned()
1018        .ok_or_else(|| anyhow!("expected TOML table to convert into a JSON object"))
1019}
1020
1021const fn default_subagents_enabled() -> bool {
1022    true
1023}
1024
1025const fn default_subagents_max_concurrent() -> usize {
1026    3
1027}
1028
1029const fn default_subagents_max_depth() -> usize {
1030    1
1031}
1032
1033const fn default_subagents_default_timeout_seconds() -> u64 {
1034    300
1035}
1036
1037const fn default_subagents_auto_delegate_read_only() -> bool {
1038    true
1039}
1040
1041const fn default_background_subagents_enabled() -> bool {
1042    false
1043}
1044
1045const fn default_background_refresh_interval_ms() -> u64 {
1046    2_000
1047}
1048
1049const fn default_background_auto_restore() -> bool {
1050    false
1051}
1052
1053fn default_background_toggle_shortcut() -> String {
1054    "ctrl+b".to_string()
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::{
1060        BackgroundSubagentConfig, SubagentDiscoveryInput, SubagentRuntimeLimits, SubagentSource,
1061        builtin_subagents, discover_subagents, load_subagent_from_file,
1062    };
1063    use crate::constants::tools;
1064    use anyhow::Result;
1065    use std::fs;
1066    use tempfile::TempDir;
1067
1068    #[test]
1069    fn parses_claude_markdown_frontmatter() -> Result<()> {
1070        let temp = TempDir::new()?;
1071        let path = temp.path().join("reviewer.md");
1072        fs::write(
1073            &path,
1074            r#"---
1075name: reviewer
1076description: Review code
1077tools: [Read, Grep, Glob]
1078disallowedTools: [Write]
1079model: sonnet
1080color: blue
1081permissionMode: plan
1082skills: [rust]
1083memory: project
1084background: true
1085maxTurns: 7
1086nickname_candidates: [rev]
1087---
1088
1089Review the target changes."#,
1090        )?;
1091
1092        let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1093        assert_eq!(spec.name, "reviewer");
1094        assert_eq!(spec.description, "Review code");
1095        assert_eq!(spec.model.as_deref(), Some("sonnet"));
1096        assert_eq!(spec.color.as_deref(), Some("blue"));
1097        assert_eq!(
1098            spec.tools,
1099            Some(vec![
1100                tools::READ_FILE.to_string(),
1101                tools::UNIFIED_SEARCH.to_string(),
1102                tools::LIST_FILES.to_string(),
1103            ])
1104        );
1105        assert_eq!(spec.disallowed_tools, vec![tools::WRITE_FILE.to_string()]);
1106        assert!(spec.background);
1107        assert_eq!(spec.max_turns, Some(7));
1108        assert_eq!(spec.prompt, "Review the target changes.");
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn normalizes_claude_tool_aliases_to_vtcode_tools() -> Result<()> {
1114        let temp = TempDir::new()?;
1115        let path = temp.path().join("debugger.md");
1116        fs::write(
1117            &path,
1118            r#"---
1119name: debugger
1120description: Debug agent
1121tools: [Read, Bash, Edit, Write, Glob, Grep]
1122disallowedTools: [Task]
1123---
1124Debug the issue."#,
1125        )?;
1126
1127        let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1128        assert_eq!(
1129            spec.tools,
1130            Some(vec![
1131                tools::READ_FILE.to_string(),
1132                tools::UNIFIED_EXEC.to_string(),
1133                tools::EDIT_FILE.to_string(),
1134                tools::WRITE_FILE.to_string(),
1135                tools::LIST_FILES.to_string(),
1136                tools::UNIFIED_SEARCH.to_string(),
1137            ])
1138        );
1139        assert_eq!(spec.disallowed_tools, vec![tools::SPAWN_AGENT.to_string()]);
1140        assert!(!spec.is_read_only());
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn shell_only_agents_are_not_read_only() -> Result<()> {
1146        let temp = TempDir::new()?;
1147        let path = temp.path().join("shell.md");
1148        fs::write(
1149            &path,
1150            r#"---
1151name: shell
1152description: Shell-capable agent
1153tools: [Bash]
1154---
1155Run shell commands."#,
1156        )?;
1157
1158        let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1159        assert_eq!(spec.tools, Some(vec![tools::UNIFIED_EXEC.to_string()]));
1160        assert!(!spec.is_read_only());
1161        Ok(())
1162    }
1163
1164    #[test]
1165    fn parses_codex_toml_definition() -> Result<()> {
1166        let temp = TempDir::new()?;
1167        let path = temp.path().join("worker.toml");
1168        fs::write(
1169            &path,
1170            r##"name = "worker"
1171description = "Write-capable implementation agent"
1172developer_instructions = "Implement the assigned change."
1173model = "gpt-5.4"
1174color = "#4f8fd8"
1175model_reasoning_effort = "high"
1176nickname_candidates = ["builder"]
1177"##,
1178        )?;
1179
1180        let spec = load_subagent_from_file(&path, SubagentSource::ProjectCodex)?;
1181        assert_eq!(spec.name, "worker");
1182        assert_eq!(spec.description, "Write-capable implementation agent");
1183        assert_eq!(spec.prompt, "Implement the assigned change.");
1184        assert_eq!(spec.model.as_deref(), Some("gpt-5.4"));
1185        assert_eq!(spec.color.as_deref(), Some("#4f8fd8"));
1186        assert_eq!(spec.reasoning_effort.as_deref(), Some("high"));
1187        assert_eq!(spec.nickname_candidates, vec!["builder".to_string()]);
1188        Ok(())
1189    }
1190
1191    #[test]
1192    fn precedence_prefers_project_vtcode_then_claude_then_codex_then_user() -> Result<()> {
1193        let temp = TempDir::new()?;
1194        fs::create_dir_all(temp.path().join(".codex/agents"))?;
1195        fs::create_dir_all(temp.path().join(".claude/agents"))?;
1196        fs::create_dir_all(temp.path().join(".vtcode/agents"))?;
1197
1198        fs::write(
1199            temp.path().join(".codex/agents/example.toml"),
1200            r#"name = "example"
1201description = "codex"
1202developer_instructions = "codex"
1203"#,
1204        )?;
1205        fs::write(
1206            temp.path().join(".claude/agents/example.md"),
1207            r#"---
1208name: example
1209description: claude
1210---
1211claude"#,
1212        )?;
1213        fs::write(
1214            temp.path().join(".vtcode/agents/example.md"),
1215            r#"---
1216name: example
1217description: vtcode
1218---
1219vtcode"#,
1220        )?;
1221
1222        let discovered =
1223            discover_subagents(&SubagentDiscoveryInput::new(temp.path().to_path_buf()))?;
1224        let effective = discovered
1225            .effective
1226            .into_iter()
1227            .find(|spec| spec.name == "example")
1228            .expect("example effective");
1229        assert_eq!(effective.description, "vtcode");
1230        assert_eq!(effective.source, SubagentSource::ProjectVtcode);
1231        Ok(())
1232    }
1233
1234    #[test]
1235    fn plugin_restrictions_strip_unsafe_overrides() -> Result<()> {
1236        let temp = TempDir::new()?;
1237        let path = temp.path().join("plugin-agent.md");
1238        fs::write(
1239            &path,
1240            r#"---
1241name: plugin-agent
1242description: Plugin agent
1243permissionMode: bypassPermissions
1244mcpServers:
1245  - github
1246hooks:
1247  PreToolUse:
1248    - matcher: Bash
1249      hooks:
1250        - type: command
1251          command: ./check.sh
1252---
1253Plugin prompt"#,
1254        )?;
1255
1256        let spec = load_subagent_from_file(
1257            &path,
1258            SubagentSource::Plugin {
1259                plugin: "demo".to_string(),
1260            },
1261        )?;
1262        assert!(spec.permission_mode.is_none());
1263        assert!(spec.mcp_servers.is_empty());
1264        assert!(spec.hooks.is_none());
1265        assert_eq!(spec.warnings.len(), 3);
1266        Ok(())
1267    }
1268
1269    #[test]
1270    fn parses_subagent_lifecycle_hooks_from_frontmatter() -> Result<()> {
1271        let temp = TempDir::new()?;
1272        let path = temp.path().join("hooks.md");
1273        fs::write(
1274            &path,
1275            r#"---
1276name: hook-agent
1277description: Hooked agent
1278hooks:
1279  SubagentStart:
1280    - matcher: worker
1281      hooks:
1282        - type: command
1283          command: echo start
1284  SubagentStop:
1285    - hooks:
1286        - type: command
1287          command: echo stop
1288---
1289Hook prompt"#,
1290        )?;
1291
1292        let spec = load_subagent_from_file(&path, SubagentSource::ProjectClaude)?;
1293        let hooks = spec.hooks.expect("hooks");
1294        assert_eq!(hooks.lifecycle.subagent_start.len(), 1);
1295        assert_eq!(hooks.lifecycle.subagent_stop.len(), 1);
1296        assert_eq!(
1297            hooks.lifecycle.subagent_start[0].matcher.as_deref(),
1298            Some("worker")
1299        );
1300        Ok(())
1301    }
1302
1303    #[test]
1304    fn builtin_aliases_cover_compat_names() {
1305        let builtins = builtin_subagents();
1306        let explorer = builtins
1307            .iter()
1308            .find(|spec| spec.name == "explorer")
1309            .expect("explorer builtin");
1310        let worker = builtins
1311            .iter()
1312            .find(|spec| spec.name == "worker")
1313            .expect("worker builtin");
1314        assert!(explorer.matches_name("explore"));
1315        assert!(worker.matches_name("general"));
1316        assert!(worker.matches_name("general-purpose"));
1317    }
1318
1319    #[test]
1320    fn background_subagent_runtime_defaults_match_documented_shortcuts() {
1321        let config = BackgroundSubagentConfig::default();
1322        assert!(!config.enabled);
1323        assert_eq!(config.default_agent, None);
1324        assert_eq!(config.refresh_interval_ms, 2_000);
1325        assert!(!config.auto_restore);
1326        assert_eq!(config.toggle_shortcut, "ctrl+b");
1327    }
1328
1329    #[test]
1330    fn subagent_runtime_limits_embed_background_defaults() {
1331        let limits = SubagentRuntimeLimits::default();
1332        assert_eq!(limits.max_concurrent, 3);
1333        assert_eq!(limits.background.default_agent, None);
1334        assert_eq!(limits.background.toggle_shortcut, "ctrl+b");
1335    }
1336
1337    #[test]
1338    fn background_subagent_runtime_deserializes_explicit_default_agent() {
1339        let config: BackgroundSubagentConfig = toml::from_str(
1340            r#"
1341enabled = true
1342default_agent = "rust-engineer"
1343refresh_interval_ms = 1500
1344auto_restore = true
1345toggle_shortcut = "ctrl+b"
1346"#,
1347        )
1348        .expect("background config");
1349
1350        assert!(config.enabled);
1351        assert_eq!(config.default_agent.as_deref(), Some("rust-engineer"));
1352        assert_eq!(config.refresh_interval_ms, 1_500);
1353        assert!(config.auto_restore);
1354        assert_eq!(config.toggle_shortcut, "ctrl+b");
1355    }
1356}