Skip to main content

vtcode_config/subagents/
discovery.rs

1//! Subagent discovery and file/CLI loading.
2
3use anyhow::{Context, Result, bail};
4use serde_json::Value as JsonValue;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use vtcode_commons::VtCodePaths;
10use vtcode_commons::reasoning::ReasoningEffortLevel;
11
12use super::parse::{
13    optional_hooks, optional_mcp_servers, optional_string_list, parse_agent_mode, parse_codex_toml_subagent,
14    parse_isolation_mode, parse_markdown_subagent, parse_memory_scope, parse_required_permissions,
15    primary_agent_subagent_only_field_warnings, required_string,
16};
17use super::permissions::{normalize_subagent_tool_list, warn_legacy_permission_rules};
18use super::{DiscoveredSubagents, SubagentDiscoveryInput, SubagentSource, SubagentSpec, builtin_subagents};
19
20pub fn discover_subagents(input: &SubagentDiscoveryInput) -> Result<DiscoveredSubagents> {
21    let mut discovered = Vec::new();
22    discovered.extend(builtin_subagents());
23
24    if input.include_user_agents
25        && let Some(home) = dirs::home_dir()
26    {
27        discovered.extend(load_subagents_from_dir(&home.join(".codex/agents"), SubagentSource::UserCodex)?);
28        discovered.extend(load_subagents_from_dir(&home.join(".claude/agents"), SubagentSource::UserClaude)?);
29        // Read the legacy root first so a canonical XDG agent with the same
30        // name wins the same-priority compatibility tie below.
31        let user_vtcode_roots = VtCodePaths::resolve()
32            .map(|paths| vec![paths.legacy_dir().join("agents"), paths.config_dir().join("agents")])
33            .unwrap_or_else(|_| vec![home.join(".vtcode/agents")]);
34        for root in user_vtcode_roots {
35            discovered.extend(load_subagents_from_dir(&root, SubagentSource::UserVtcode)?);
36        }
37    }
38
39    discovered
40        .extend(load_subagents_from_dir(&input.workspace_root.join(".codex/agents"), SubagentSource::ProjectCodex)?);
41    discovered.extend(load_subagents_from_dir(
42        &input.workspace_root.join(".claude/agents"),
43        SubagentSource::ProjectClaude,
44    )?);
45    discovered.extend(load_subagents_from_dir(
46        &input.workspace_root.join(".vtcode/agents"),
47        SubagentSource::ProjectVtcode,
48    )?);
49
50    for (plugin_name, path) in &input.plugin_agent_files {
51        if !path.exists() || !path.is_file() {
52            continue;
53        }
54        let source = SubagentSource::Plugin { plugin: plugin_name.clone() };
55        discovered.push(load_subagent_from_file(path, source)?);
56    }
57
58    if let Some(cli_agents) = input.cli_agents.as_ref() {
59        discovered.extend(load_cli_agents(cli_agents)?);
60    }
61
62    discovered.sort_by_key(|spec| spec.source.priority());
63
64    let mut effective_by_name: BTreeMap<String, SubagentSpec> = BTreeMap::new();
65    let mut shadowed = Vec::new();
66    for spec in discovered {
67        let key = spec.name.clone();
68        if let Some(existing) = effective_by_name.get(&key) {
69            if should_replace(existing, &spec) {
70                shadowed.push(existing.clone());
71                effective_by_name.insert(key, spec);
72            } else {
73                shadowed.push(spec);
74            }
75        } else {
76            effective_by_name.insert(key, spec);
77        }
78    }
79
80    Ok(DiscoveredSubagents {
81        effective: effective_by_name.into_values().collect(),
82        shadowed,
83    })
84}
85
86fn should_replace(existing: &SubagentSpec, candidate: &SubagentSpec) -> bool {
87    let existing_priority = existing.source.priority();
88    let candidate_priority = candidate.source.priority();
89    if candidate_priority != existing_priority {
90        return candidate_priority < existing_priority;
91    }
92
93    candidate.source.vtcode_native() && !existing.source.vtcode_native()
94}
95
96fn load_subagents_from_dir(dir: &Path, source: SubagentSource) -> Result<Vec<SubagentSpec>> {
97    if !dir.exists() || !dir.is_dir() {
98        return Ok(Vec::new());
99    }
100
101    let extension = match source {
102        SubagentSource::ProjectCodex | SubagentSource::UserCodex => "toml",
103        _ => "md",
104    };
105    let mut loaded = Vec::new();
106    for entry in fs::read_dir(dir).with_context(|| format!("failed to read subagent directory {}", dir.display()))? {
107        let entry = entry?;
108        let path = entry.path();
109        if !path.is_file() {
110            continue;
111        }
112        if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
113            continue;
114        }
115        loaded.push(load_subagent_from_file(&path, source.clone())?);
116    }
117
118    Ok(loaded)
119}
120
121pub fn load_subagent_from_file(path: &Path, source: SubagentSource) -> Result<SubagentSpec> {
122    let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
123    let mut spec = match source {
124        SubagentSource::ProjectCodex | SubagentSource::UserCodex => {
125            parse_codex_toml_subagent(&content, source.clone())?
126        }
127        _ => parse_markdown_subagent(&content, source.clone())?,
128    };
129    spec.file_path = Some(path.to_path_buf());
130    Ok(spec)
131}
132
133pub(super) fn load_cli_agents(value: &JsonValue) -> Result<Vec<SubagentSpec>> {
134    let Some(object) = value.as_object() else {
135        bail!("CLI subagent payload must be a JSON object");
136    };
137
138    let mut specs = Vec::with_capacity(object.len());
139    for (name, raw) in object {
140        let Some(config) = raw.as_object() else {
141            bail!("CLI subagent '{name}' must be an object");
142        };
143        let description = required_string(config, "description")
144            .with_context(|| format!("CLI subagent '{name}' is missing description"))?;
145        let prompt = config.get("prompt").and_then(JsonValue::as_str).unwrap_or_default().to_string();
146        let tools = optional_string_list(config.get("tools"))?;
147        let disallowed_tools = optional_string_list(config.get("disallowedTools"))?.unwrap_or_default();
148        let model = config.get("model").and_then(JsonValue::as_str).map(ToString::to_string);
149        let color = config
150            .get("color")
151            .or_else(|| config.get("badgeColor"))
152            .or_else(|| config.get("badge_color"))
153            .and_then(JsonValue::as_str)
154            .map(ToString::to_string);
155        let reasoning_effort = config
156            .get("reasoning_effort")
157            .or_else(|| config.get("model_reasoning_effort"))
158            .or_else(|| config.get("effort"))
159            .and_then(JsonValue::as_str)
160            .and_then(ReasoningEffortLevel::parse);
161        let permissions = parse_required_permissions(config)?;
162        let skills = optional_string_list(config.get("skills"))?.unwrap_or_default();
163        let mcp_servers = optional_mcp_servers(config.get("mcpServers").or_else(|| config.get("mcp_servers")))?;
164        let hooks = optional_hooks(config.get("hooks"))?;
165        let max_turns = config
166            .get("maxTurns")
167            .or_else(|| config.get("max_turns"))
168            .and_then(JsonValue::as_u64)
169            .map(|value| value as usize);
170        let background = config.get("background").and_then(JsonValue::as_bool).unwrap_or(false);
171        let mode = config
172            .get("mode")
173            .and_then(JsonValue::as_str)
174            .map(parse_agent_mode)
175            .transpose()?
176            .unwrap_or_default();
177        let nickname_candidates = optional_string_list(config.get("nickname_candidates"))?.unwrap_or_default();
178        let initial_prompt = config
179            .get("initialPrompt")
180            .or_else(|| config.get("initial_prompt"))
181            .and_then(JsonValue::as_str)
182            .map(ToString::to_string);
183        let memory = config
184            .get("memory")
185            .and_then(JsonValue::as_str)
186            .map(parse_memory_scope)
187            .transpose()?;
188        let isolation = config
189            .get("isolation")
190            .and_then(JsonValue::as_str)
191            .map(parse_isolation_mode)
192            .transpose()?;
193        let aliases = optional_string_list(config.get("aliases"))?.unwrap_or_default();
194        let mut warnings = primary_agent_subagent_only_field_warnings(config, mode);
195        warnings.extend(warn_legacy_permission_rules(&permissions));
196
197        specs.push(SubagentSpec {
198            name: name.clone(),
199            description,
200            prompt,
201            tools,
202            disallowed_tools,
203            model,
204            color,
205            reasoning_effort,
206            permissions,
207            skills,
208            mcp_servers,
209            hooks,
210            background,
211            mode,
212            max_turns,
213            nickname_candidates,
214            initial_prompt,
215            memory,
216            isolation,
217            aliases,
218            source: SubagentSource::Cli,
219            file_path: None,
220            warnings,
221            tool_policy_overrides: BTreeMap::new(),
222        });
223    }
224
225    Ok(specs)
226}