Skip to main content

vtcode_config/subagents/
mod.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};
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/// Subagent isolation mode
102#[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 isolation (separate process)
107    Full,
108    /// Git worktree isolation
109    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    /// Per-tool policy overrides applied when this agent becomes active.
177    /// Keys are tool names, values are the policy to enforce.
178    /// Applied on top of (and overriding) the global `[tools.policies]` from vtcode.toml.
179    #[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
391/// Hard ceiling for subagent concurrency. The configurable `max_concurrent` is
392/// clamped to this value at runtime so it can never be exceeded.
393pub 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;