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};
7use std::sync::LazyLock;
8use vtcode_commons::VtCodePaths;
9use vtcode_commons::reasoning::ReasoningEffortLevel;
10
11use crate::constants::tools;
12use crate::constants::ui;
13use crate::core::permissions::{AgentPermissionsConfig, PermissionDefault};
14use crate::core::tools::ToolPolicy;
15use crate::hooks::{HookCommandConfig, HookCommandKind, HookGroupConfig, HooksConfig};
16
17use self::permissions::is_mutating_tool_name;
18
19mod builtin;
20mod discovery;
21mod parse;
22mod permissions;
23
24pub use builtin::{
25 builtin_plan_agent, builtin_primary_auto_agent, builtin_primary_build_agent, builtin_primary_coordinator_agent,
26 builtin_primary_duck_agent, builtin_subagents,
27};
28pub use discovery::{discover_subagents, load_subagent_from_file};
29
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum SubagentSource {
34 Cli,
35 ProjectVtcode,
36 ProjectClaude,
37 ProjectCodex,
38 UserVtcode,
39 UserClaude,
40 UserCodex,
41 Plugin { plugin: String },
42 Builtin,
43}
44
45impl SubagentSource {
46 #[must_use]
47 const fn priority(&self) -> usize {
48 match self {
49 Self::Cli => 0,
50 Self::ProjectVtcode => 1,
51 Self::ProjectClaude => 2,
52 Self::ProjectCodex => 3,
53 Self::UserVtcode => 4,
54 Self::UserClaude => 5,
55 Self::UserCodex => 6,
56 Self::Plugin { .. } => 7,
57 Self::Builtin => 8,
58 }
59 }
60
61 #[must_use]
62 pub fn label(&self) -> String {
63 match self {
64 Self::Cli => "cli".to_string(),
65 Self::ProjectVtcode => "project:.vtcode".to_string(),
66 Self::ProjectClaude => "project:.claude".to_string(),
67 Self::ProjectCodex => "project:.codex".to_string(),
68 Self::UserVtcode => "user:canonical-config".to_string(),
69 Self::UserClaude => "user:~/.claude".to_string(),
70 Self::UserCodex => "user:~/.codex".to_string(),
71 Self::Plugin { plugin } => format!("plugin:{plugin}"),
72 Self::Builtin => "builtin".to_string(),
73 }
74 }
75
76 #[must_use]
77 const fn vtcode_native(&self) -> bool {
78 matches!(self, Self::ProjectVtcode | Self::UserVtcode | Self::Cli)
79 }
80}
81
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum SubagentMemoryScope {
86 User,
87 Project,
88 Local,
89}
90
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
93#[serde(rename_all = "snake_case")]
94pub enum AgentMode {
95 Primary,
96 #[default]
97 Subagent,
98 All,
99}
100
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum IsolationMode {
106 Full,
108 Worktree,
110}
111
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
114#[serde(untagged)]
115pub enum SubagentMcpServer {
116 Named(String),
117 Inline(BTreeMap<String, JsonValue>),
118}
119
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum AgentSpecFieldClass {
124 Shared,
125 PrimaryMetadata,
126 PrimaryRuntime,
127 SubagentOnly,
128 Availability,
129}
130
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[derive(Debug, Clone, Deserialize, Serialize)]
133pub struct SubagentSpec {
134 pub name: String,
135 pub description: String,
136 #[serde(default)]
137 pub prompt: String,
138 #[serde(default)]
139 pub tools: Option<Vec<String>>,
140 #[serde(default)]
141 pub disallowed_tools: Vec<String>,
142 #[serde(default)]
143 pub model: Option<String>,
144 #[serde(default)]
145 pub color: Option<String>,
146 #[serde(default)]
147 pub reasoning_effort: Option<ReasoningEffortLevel>,
148 pub permissions: AgentPermissionsConfig,
149 #[serde(default)]
150 pub skills: Vec<String>,
151 #[serde(default)]
152 pub mcp_servers: Vec<SubagentMcpServer>,
153 #[serde(default)]
154 pub hooks: Option<HooksConfig>,
155 #[serde(default)]
156 pub background: bool,
157 #[serde(default)]
158 pub mode: AgentMode,
159 #[serde(default)]
160 pub max_turns: Option<usize>,
161 #[serde(default)]
162 pub nickname_candidates: Vec<String>,
163 #[serde(default)]
164 pub initial_prompt: Option<String>,
165 #[serde(default)]
166 pub memory: Option<SubagentMemoryScope>,
167 #[serde(default)]
168 pub isolation: Option<IsolationMode>,
169 #[serde(default)]
170 pub aliases: Vec<String>,
171 pub source: SubagentSource,
172 #[serde(default)]
173 pub file_path: Option<PathBuf>,
174 #[serde(default)]
175 pub warnings: Vec<String>,
176 #[serde(default)]
180 pub tool_policy_overrides: BTreeMap<String, ToolPolicy>,
181}
182
183impl Default for SubagentSpec {
184 fn default() -> Self {
185 Self {
186 name: String::new(),
187 description: String::new(),
188 prompt: String::new(),
189 tools: None,
190 disallowed_tools: Vec::new(),
191 model: None,
192 color: None,
193 reasoning_effort: None,
194 permissions: AgentPermissionsConfig {
195 default: PermissionDefault::Ask,
196 allow: Vec::new(),
197 ask: Vec::new(),
198 auto: Vec::new(),
199 deny: Vec::new(),
200 },
201 skills: Vec::new(),
202 mcp_servers: Vec::new(),
203 hooks: None,
204 background: false,
205 mode: AgentMode::default(),
206 max_turns: None,
207 nickname_candidates: Vec::new(),
208 initial_prompt: None,
209 memory: None,
210 isolation: None,
211 aliases: Vec::new(),
212 source: SubagentSource::Builtin,
213 file_path: None,
214 warnings: Vec::new(),
215 tool_policy_overrides: BTreeMap::new(),
216 }
217 }
218}
219
220impl SubagentSpec {
221 #[must_use]
222 pub const fn is_primary(&self) -> bool {
223 matches!(self.mode, AgentMode::Primary | AgentMode::All)
224 }
225
226 #[must_use]
227 pub const fn is_subagent(&self) -> bool {
228 matches!(self.mode, AgentMode::Subagent | AgentMode::All)
229 }
230
231 #[must_use]
232 pub fn is_read_only(&self) -> bool {
233 if !self.permissions_allows_mutation() {
234 return true;
235 }
236
237 let tools = self.tools.as_ref().map_or_else(Vec::new, Clone::clone);
238 let lower_tools = tools.iter().map(|tool| tool.to_ascii_lowercase()).collect::<Vec<_>>();
239 let lower_denied = self
240 .disallowed_tools
241 .iter()
242 .map(|tool| tool.to_ascii_lowercase())
243 .collect::<Vec<_>>();
244
245 let denies_writes = lower_denied.iter().any(|tool| is_mutating_tool_name(tool.as_str()));
246
247 if self.tools.is_some() {
248 let exposes_mutation = lower_tools.iter().any(|tool| is_mutating_tool_name(tool.as_str()));
249 !exposes_mutation
250 } else {
251 denies_writes
252 }
253 }
254
255 #[must_use]
256 fn permissions_allows_mutation(&self) -> bool {
257 if matches!(
258 self.permissions.default,
259 PermissionDefault::Ask | PermissionDefault::Allow | PermissionDefault::Auto
260 ) {
261 return true;
262 }
263
264 self.permissions
265 .allow
266 .iter()
267 .chain(self.permissions.auto.iter())
268 .map(|rule| rule.to_ascii_lowercase())
269 .any(|rule| is_mutating_tool_name(rule.as_str()))
270 }
271
272 #[must_use]
273 pub fn matches_name(&self, candidate: &str) -> bool {
274 self.name.eq_ignore_ascii_case(candidate)
275 || self.aliases.iter().any(|alias| alias.eq_ignore_ascii_case(candidate))
276 }
277}
278
279#[must_use]
280pub(crate) fn classify_agent_spec_field(field: &str) -> Option<AgentSpecFieldClass> {
281 match field.trim() {
282 "name" | "prompt" => Some(AgentSpecFieldClass::Shared),
283 "description" | "color" | "aliases" => Some(AgentSpecFieldClass::PrimaryMetadata),
284 "tools" | "disallowed_tools" | "disallowedTools" | "permissions" | "model" | "reasoning_effort" | "skills"
285 | "mcp_servers" | "mcpServers" | "hooks" | "memory" => Some(AgentSpecFieldClass::PrimaryRuntime),
286 "background"
287 | "max_turns"
288 | "maxTurns"
289 | "initial_prompt"
290 | "initialPrompt"
291 | "nickname_candidates"
292 | "isolation" => Some(AgentSpecFieldClass::SubagentOnly),
293 "mode" => Some(AgentSpecFieldClass::Availability),
294 _ => None,
295 }
296}
297
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
300pub struct BackgroundSubagentConfig {
301 #[serde(default = "default_background_subagents_enabled")]
302 pub enabled: bool,
303 #[serde(default)]
304 pub default_agent: Option<String>,
305 #[serde(default = "default_background_refresh_interval_ms")]
306 pub refresh_interval_ms: u64,
307 #[serde(default = "default_background_auto_restore")]
308 pub auto_restore: bool,
309 #[serde(default = "default_background_toggle_shortcut")]
310 toggle_shortcut: String,
311}
312
313impl Default for BackgroundSubagentConfig {
314 fn default() -> Self {
315 Self {
316 enabled: default_background_subagents_enabled(),
317 default_agent: None,
318 refresh_interval_ms: default_background_refresh_interval_ms(),
319 auto_restore: default_background_auto_restore(),
320 toggle_shortcut: default_background_toggle_shortcut(),
321 }
322 }
323}
324
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
327pub struct SubagentRuntimeLimits {
328 #[serde(default = "default_subagents_enabled")]
329 pub enabled: bool,
330 #[serde(default = "default_subagents_max_concurrent")]
331 pub max_concurrent: usize,
332 #[serde(default = "default_subagents_max_depth")]
333 pub max_depth: usize,
334 #[serde(default = "default_subagents_default_timeout_seconds")]
335 pub default_timeout_seconds: u64,
336 #[serde(default = "default_subagents_auto_delegate_read_only")]
337 pub auto_delegate_read_only: bool,
338 #[serde(default)]
339 pub background: BackgroundSubagentConfig,
340}
341
342impl Default for SubagentRuntimeLimits {
343 fn default() -> Self {
344 Self {
345 enabled: default_subagents_enabled(),
346 max_concurrent: default_subagents_max_concurrent(),
347 max_depth: default_subagents_max_depth(),
348 default_timeout_seconds: default_subagents_default_timeout_seconds(),
349 auto_delegate_read_only: default_subagents_auto_delegate_read_only(),
350 background: BackgroundSubagentConfig::default(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Default)]
356pub struct DiscoveredSubagents {
357 pub effective: Vec<SubagentSpec>,
358 pub shadowed: Vec<SubagentSpec>,
359}
360
361#[derive(Debug, Clone)]
362pub struct SubagentDiscoveryInput {
363 pub workspace_root: PathBuf,
364 pub cli_agents: Option<JsonValue>,
365 pub plugin_agent_files: Vec<(String, PathBuf)>,
366 pub include_user_agents: bool,
367}
368
369impl SubagentDiscoveryInput {
370 #[must_use]
371 pub fn new(workspace_root: PathBuf) -> Self {
372 Self {
373 workspace_root,
374 cli_agents: None,
375 plugin_agent_files: Vec::new(),
376 include_user_agents: true,
377 }
378 }
379}
380
381impl Default for SubagentDiscoveryInput {
382 fn default() -> Self {
383 Self::new(PathBuf::new())
384 }
385}
386
387const fn default_subagents_enabled() -> bool {
388 true
389}
390
391pub const SUBAGENT_HARD_CONCURRENCY_LIMIT: usize = 5;
394
395const fn default_subagents_max_concurrent() -> usize {
396 3
397}
398
399const fn default_subagents_max_depth() -> usize {
400 1
401}
402
403const fn default_subagents_default_timeout_seconds() -> u64 {
404 300
405}
406
407const fn default_subagents_auto_delegate_read_only() -> bool {
408 true
409}
410
411const fn default_background_subagents_enabled() -> bool {
412 false
413}
414
415const fn default_background_refresh_interval_ms() -> u64 {
416 2_000
417}
418
419const fn default_background_auto_restore() -> bool {
420 false
421}
422
423fn default_background_toggle_shortcut() -> String {
424 "ctrl+b".to_string()
425}
426
427#[cfg(test)]
428mod tests;