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        // `readdir` already reported the type; skip the extra `stat`.
110        if !vtcode_commons::walk::entry_is_file(&entry) {
111            continue;
112        }
113        if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
114            continue;
115        }
116        loaded.push(load_subagent_from_file(&path, source.clone())?);
117    }
118
119    Ok(loaded)
120}
121
122pub fn load_subagent_from_file(path: &Path, source: SubagentSource) -> Result<SubagentSpec> {
123    let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
124    let mut spec = match source {
125        SubagentSource::ProjectCodex | SubagentSource::UserCodex => {
126            parse_codex_toml_subagent(&content, source.clone())?
127        }
128        _ => parse_markdown_subagent(&content, source.clone())?,
129    };
130    spec.file_path = Some(path.to_path_buf());
131    Ok(spec)
132}
133
134pub(super) fn load_cli_agents(value: &JsonValue) -> Result<Vec<SubagentSpec>> {
135    let Some(object) = value.as_object() else {
136        bail!("CLI subagent payload must be a JSON object");
137    };
138
139    let mut specs = Vec::with_capacity(object.len());
140    for (name, raw) in object {
141        let Some(config) = raw.as_object() else {
142            bail!("CLI subagent '{name}' must be an object");
143        };
144        let description = required_string(config, "description")
145            .with_context(|| format!("CLI subagent '{name}' is missing description"))?;
146        let prompt = config.get("prompt").and_then(JsonValue::as_str).unwrap_or_default().to_string();
147        let tools = optional_string_list(config.get("tools"))?;
148        let disallowed_tools = optional_string_list(config.get("disallowedTools"))?.unwrap_or_default();
149        let model = config.get("model").and_then(JsonValue::as_str).map(ToString::to_string);
150        let color = config
151            .get("color")
152            .or_else(|| config.get("badgeColor"))
153            .or_else(|| config.get("badge_color"))
154            .and_then(JsonValue::as_str)
155            .map(ToString::to_string);
156        let reasoning_effort = config
157            .get("reasoning_effort")
158            .or_else(|| config.get("model_reasoning_effort"))
159            .or_else(|| config.get("effort"))
160            .and_then(JsonValue::as_str)
161            .and_then(ReasoningEffortLevel::parse);
162        let permissions = parse_required_permissions(config)?;
163        let skills = optional_string_list(config.get("skills"))?.unwrap_or_default();
164        let mcp_servers = optional_mcp_servers(config.get("mcpServers").or_else(|| config.get("mcp_servers")))?;
165        let hooks = optional_hooks(config.get("hooks"))?;
166        let max_turns = config
167            .get("maxTurns")
168            .or_else(|| config.get("max_turns"))
169            .and_then(JsonValue::as_u64)
170            .map(|value| value as usize);
171        let background = config.get("background").and_then(JsonValue::as_bool).unwrap_or(false);
172        let mode = config
173            .get("mode")
174            .and_then(JsonValue::as_str)
175            .map(parse_agent_mode)
176            .transpose()?
177            .unwrap_or_default();
178        let nickname_candidates = optional_string_list(config.get("nickname_candidates"))?.unwrap_or_default();
179        let initial_prompt = config
180            .get("initialPrompt")
181            .or_else(|| config.get("initial_prompt"))
182            .and_then(JsonValue::as_str)
183            .map(ToString::to_string);
184        let memory = config
185            .get("memory")
186            .and_then(JsonValue::as_str)
187            .map(parse_memory_scope)
188            .transpose()?;
189        let isolation = config
190            .get("isolation")
191            .and_then(JsonValue::as_str)
192            .map(parse_isolation_mode)
193            .transpose()?;
194        let aliases = optional_string_list(config.get("aliases"))?.unwrap_or_default();
195        let mut warnings = primary_agent_subagent_only_field_warnings(config, mode);
196        warnings.extend(warn_legacy_permission_rules(&permissions));
197
198        specs.push(SubagentSpec {
199            name: name.clone(),
200            description,
201            prompt,
202            tools,
203            disallowed_tools,
204            model,
205            color,
206            reasoning_effort,
207            permissions,
208            skills,
209            mcp_servers,
210            hooks,
211            background,
212            mode,
213            max_turns,
214            nickname_candidates,
215            initial_prompt,
216            memory,
217            isolation,
218            aliases,
219            source: SubagentSource::Cli,
220            file_path: None,
221            warnings,
222            tool_policy_overrides: BTreeMap::new(),
223        });
224    }
225
226    Ok(specs)
227}