vtcode_config/subagents/
discovery.rs1use 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 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}