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