Skip to main content

vtcode_config/
subagent.rs

1//! Subagent configuration schema and parsing
2//!
3//! Subagents are specialized AI agents that can be invoked for specific tasks.
4//! Built-in subagents are shipped with the binary.
5//!
6//! # Built-in subagents include:
7//! - explore: Fast read-only codebase search
8//! - plan: Research for planning mode
9//! - general: Multi-step tasks with full capabilities
10//! - code-reviewer: Code quality and security review
11//! - debugger: Error investigation and fixes
12
13use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::str::FromStr;
17use tracing::debug;
18
19/// Permission mode for subagent tool execution
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum SubagentPermissionMode {
23    /// Normal permission prompts
24    #[default]
25    Default,
26    /// Auto-accept file edits
27    AcceptEdits,
28    /// Bypass all permission prompts (dangerous)
29    BypassPermissions,
30    /// Plan mode - research only, no modifications
31    Plan,
32    /// Ignore permission errors and continue
33    Ignore,
34}
35
36impl fmt::Display for SubagentPermissionMode {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Default => write!(f, "default"),
40            Self::AcceptEdits => write!(f, "acceptEdits"),
41            Self::BypassPermissions => write!(f, "bypassPermissions"),
42            Self::Plan => write!(f, "plan"),
43            Self::Ignore => write!(f, "ignore"),
44        }
45    }
46}
47
48impl FromStr for SubagentPermissionMode {
49    type Err = String;
50
51    fn from_str(s: &str) -> Result<Self, Self::Err> {
52        match s.to_lowercase().as_str() {
53            "default" => Ok(Self::Default),
54            "acceptedits" | "accept_edits" | "accept-edits" => Ok(Self::AcceptEdits),
55            "bypasspermissions" | "bypass_permissions" | "bypass-permissions" => {
56                Ok(Self::BypassPermissions)
57            }
58            "plan" => Ok(Self::Plan),
59            "ignore" => Ok(Self::Ignore),
60            _ => Err(format!("Unknown permission mode: {}", s)),
61        }
62    }
63}
64
65/// Model selection for subagent
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(untagged)]
68pub enum SubagentModel {
69    /// Inherit model from parent conversation
70    Inherit,
71    /// Use a specific model alias (sonnet, opus, haiku)
72    Alias(String),
73    /// Use a specific model ID
74    ModelId(String),
75}
76
77impl Default for SubagentModel {
78    fn default() -> Self {
79        Self::Alias("sonnet".to_string())
80    }
81}
82
83impl fmt::Display for SubagentModel {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Inherit => write!(f, "inherit"),
87            Self::Alias(alias) => write!(f, "{}", alias),
88            Self::ModelId(id) => write!(f, "{}", id),
89        }
90    }
91}
92
93impl FromStr for SubagentModel {
94    type Err = std::convert::Infallible;
95
96    fn from_str(s: &str) -> Result<Self, Self::Err> {
97        if s.eq_ignore_ascii_case("inherit") {
98            Ok(Self::Inherit)
99        } else if matches!(s.to_lowercase().as_str(), "sonnet" | "opus" | "haiku") {
100            Ok(Self::Alias(s.to_lowercase()))
101        } else {
102            Ok(Self::ModelId(s.to_string()))
103        }
104    }
105}
106
107/// Source location of a subagent definition
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum SubagentSource {
110    /// Built-in subagent shipped with the binary
111    Builtin,
112    /// User-level subagent from ~/.vtcode/agents/
113    User,
114    /// Project-level subagent from .vtcode/agents/
115    Project,
116    /// Plugin-provided subagent
117    Plugin(String),
118}
119
120impl fmt::Display for SubagentSource {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::Builtin => write!(f, "builtin"),
124            Self::User => write!(f, "user"),
125            Self::Project => write!(f, "project"),
126            Self::Plugin(name) => write!(f, "plugin:{}", name),
127        }
128    }
129}
130
131/// YAML frontmatter parsed from subagent markdown file
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SubagentFrontmatter {
134    /// Unique identifier (lowercase, hyphens allowed)
135    pub name: String,
136
137    /// Natural language description of when to use this subagent
138    pub description: String,
139
140    /// Comma-separated list of allowed tools (inherits all if omitted)
141    #[serde(default)]
142    pub tools: Option<String>,
143
144    /// Model to use (alias, model ID, or "inherit")
145    #[serde(default)]
146    pub model: Option<String>,
147
148    /// Permission mode for tool execution
149    #[serde(default, rename = "permissionMode")]
150    pub permission_mode: Option<String>,
151
152    /// Comma-separated list of skills to auto-load
153    #[serde(default)]
154    pub skills: Option<String>,
155}
156
157/// Complete subagent configuration
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SubagentConfig {
160    /// Unique identifier
161    pub name: String,
162
163    /// Human-readable description for delegation
164    pub description: String,
165
166    /// Allowed tools (None = inherit all from parent)
167    pub tools: Option<Vec<String>>,
168
169    /// Model selection
170    pub model: SubagentModel,
171
172    /// Permission mode
173    pub permission_mode: SubagentPermissionMode,
174
175    /// Skills to auto-load
176    pub skills: Vec<String>,
177
178    /// System prompt (markdown body)
179    pub system_prompt: String,
180
181    /// Source location
182    pub source: SubagentSource,
183
184    /// File path (if loaded from file)
185    pub file_path: Option<PathBuf>,
186}
187
188impl SubagentConfig {
189    /// Parse a subagent from markdown content with YAML frontmatter
190    pub fn from_markdown(
191        content: &str,
192        source: SubagentSource,
193        file_path: Option<PathBuf>,
194    ) -> Result<Self, SubagentParseError> {
195        debug!(
196            ?source,
197            ?file_path,
198            content_len = content.len(),
199            "Parsing subagent from markdown"
200        );
201        // Extract YAML frontmatter between --- delimiters
202        let content = content.trim();
203        if !content.starts_with("---") {
204            return Err(SubagentParseError::MissingFrontmatter);
205        }
206
207        let after_start = &content[3..];
208        let end_pos = after_start
209            .find("\n---")
210            .ok_or(SubagentParseError::MissingFrontmatter)?;
211
212        let yaml_content = &after_start[..end_pos].trim();
213        let body_start = 3 + end_pos + 4; // Skip "---\n" + yaml + "\n---"
214        let system_prompt = content
215            .get(body_start..)
216            .map(|s| s.trim())
217            .unwrap_or("")
218            .to_string();
219
220        // Parse YAML frontmatter
221        let frontmatter: SubagentFrontmatter =
222            serde_yaml::from_str(yaml_content).map_err(SubagentParseError::YamlError)?;
223
224        // Parse tools list
225        let tools = frontmatter.tools.map(|t| {
226            t.split(',')
227                .map(|s| s.trim().to_string())
228                .filter(|s| !s.is_empty())
229                .collect()
230        });
231
232        // Parse model
233        let model = frontmatter
234            .model
235            .map(|m| SubagentModel::from_str(&m).unwrap())
236            .unwrap_or_default();
237
238        // Parse permission mode
239        let permission_mode = frontmatter
240            .permission_mode
241            .map(|p| SubagentPermissionMode::from_str(&p).unwrap_or_default())
242            .unwrap_or_default();
243
244        // Parse skills list
245        let skills = frontmatter
246            .skills
247            .map(|s| {
248                s.split(',')
249                    .map(|s| s.trim().to_string())
250                    .filter(|s| !s.is_empty())
251                    .collect()
252            })
253            .unwrap_or_default();
254
255        let config = Self {
256            name: frontmatter.name.clone(),
257            description: frontmatter.description.clone(),
258            tools,
259            model,
260            permission_mode,
261            skills,
262            system_prompt,
263            source,
264            file_path,
265        };
266        debug!(
267            name = %config.name,
268            ?config.model,
269            ?config.permission_mode,
270            tools_count = config.tools.as_ref().map(|t| t.len()),
271            "Parsed subagent config"
272        );
273        Ok(config)
274    }
275
276    /// Parse subagent from JSON (for CLI --agents flag)
277    pub fn from_json(name: &str, value: &serde_json::Value) -> Result<Self, SubagentParseError> {
278        let description = value
279            .get("description")
280            .and_then(|v| v.as_str())
281            .ok_or_else(|| SubagentParseError::MissingField("description".to_string()))?
282            .to_string();
283
284        let system_prompt = value
285            .get("prompt")
286            .and_then(|v| v.as_str())
287            .unwrap_or("")
288            .to_string();
289
290        let tools = value.get("tools").and_then(|v| {
291            v.as_array().map(|arr| {
292                arr.iter()
293                    .filter_map(|v| v.as_str().map(String::from))
294                    .collect()
295            })
296        });
297
298        let model = value
299            .get("model")
300            .and_then(|v| v.as_str())
301            .map(|m| SubagentModel::from_str(m).unwrap())
302            .unwrap_or_default();
303
304        let permission_mode = value
305            .get("permissionMode")
306            .and_then(|v| v.as_str())
307            .map(|p| SubagentPermissionMode::from_str(p).unwrap_or_default())
308            .unwrap_or_default();
309
310        let skills = value
311            .get("skills")
312            .and_then(|v| v.as_array())
313            .map(|arr| {
314                arr.iter()
315                    .filter_map(|v| v.as_str().map(String::from))
316                    .collect()
317            })
318            .unwrap_or_default();
319
320        Ok(Self {
321            name: name.to_string(),
322            description,
323            tools,
324            model,
325            permission_mode,
326            skills,
327            system_prompt,
328            source: SubagentSource::User, // Default to User source for JSON-parsed agents
329            file_path: None,
330        })
331    }
332
333    /// Check if this subagent has access to a specific tool
334    pub fn has_tool_access(&self, tool_name: &str) -> bool {
335        match &self.tools {
336            None => true, // Inherits all tools
337            Some(tools) => tools.iter().any(|t| t == tool_name),
338        }
339    }
340
341    /// Get the list of allowed tools, or None if all tools are allowed
342    pub fn allowed_tools(&self) -> Option<&[String]> {
343        self.tools.as_deref()
344    }
345
346    /// Check if this is a read-only subagent (like Explore)
347    pub fn is_read_only(&self) -> bool {
348        self.permission_mode == SubagentPermissionMode::Plan
349    }
350}
351
352/// Errors that can occur when parsing subagent configurations
353#[derive(Debug, thiserror::Error)]
354pub enum SubagentParseError {
355    /// Missing YAML frontmatter
356    #[error("Missing YAML frontmatter (---...---)")]
357    MissingFrontmatter,
358    /// YAML parsing error
359    #[error("YAML parse error: {0}")]
360    YamlError(#[from] serde_yaml::Error),
361    /// Missing required field
362    #[error("Missing required field: {0}")]
363    MissingField(String),
364    /// IO error when reading file
365    #[error("IO error: {0}")]
366    IoError(#[from] std::io::Error),
367}
368
369/// Configuration for the subagent system in vtcode.toml
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
372pub struct SubagentsConfig {
373    /// Enable the subagent system
374    #[serde(default = "default_enabled")]
375    pub enabled: bool,
376
377    /// Maximum concurrent subagents
378    #[serde(default = "default_max_concurrent")]
379    pub max_concurrent: usize,
380
381    /// Default timeout for subagent execution (seconds)
382    #[serde(default = "default_timeout_seconds")]
383    pub default_timeout_seconds: u64,
384
385    /// Default model for subagents (if not specified in subagent config)
386    #[serde(default)]
387    pub default_model: Option<String>,
388
389    /// Additional directories to search for subagent definitions
390    #[serde(default)]
391    pub additional_agent_dirs: Vec<PathBuf>,
392}
393
394fn default_enabled() -> bool {
395    true
396}
397
398fn default_max_concurrent() -> usize {
399    3
400}
401
402fn default_timeout_seconds() -> u64 {
403    300 // 5 minutes
404}
405
406impl Default for SubagentsConfig {
407    fn default() -> Self {
408        Self {
409            enabled: default_enabled(),
410            max_concurrent: default_max_concurrent(),
411            default_timeout_seconds: default_timeout_seconds(),
412            default_model: None,
413            additional_agent_dirs: Vec::new(),
414        }
415    }
416}
417
418/// Load subagent from a markdown file
419pub fn load_subagent_from_file(
420    path: &Path,
421    source: SubagentSource,
422) -> Result<SubagentConfig, SubagentParseError> {
423    let content = std::fs::read_to_string(path)?;
424    SubagentConfig::from_markdown(&content, source, Some(path.to_path_buf()))
425}
426
427/// Discover all subagent files in a directory
428pub fn discover_subagents_in_dir(
429    dir: &Path,
430    source: SubagentSource,
431) -> Vec<Result<SubagentConfig, SubagentParseError>> {
432    let mut results = Vec::new();
433
434    if !dir.exists() || !dir.is_dir() {
435        return results;
436    }
437
438    if let Ok(entries) = std::fs::read_dir(dir) {
439        for entry in entries.flatten() {
440            let path = entry.path();
441            if path.extension().map(|e| e == "md").unwrap_or(false) {
442                results.push(load_subagent_from_file(&path, source.clone()));
443            }
444        }
445    }
446
447    results
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_parse_subagent_markdown() {
456        let content = r#"---
457name: code-reviewer
458description: Expert code reviewer for quality and security
459tools: read_file, grep_file, list_files
460model: sonnet
461permissionMode: default
462skills: rust-patterns
463---
464
465You are a senior code reviewer.
466Focus on quality, security, and best practices.
467"#;
468
469        let config = SubagentConfig::from_markdown(content, SubagentSource::User, None).unwrap();
470
471        assert_eq!(config.name, "code-reviewer");
472        assert_eq!(
473            config.description,
474            "Expert code reviewer for quality and security"
475        );
476        assert_eq!(
477            config.tools,
478            Some(vec![
479                "read_file".to_string(),
480                "grep_file".to_string(),
481                "list_files".to_string()
482            ])
483        );
484        assert_eq!(config.model, SubagentModel::Alias("sonnet".to_string()));
485        assert_eq!(config.permission_mode, SubagentPermissionMode::Default);
486        assert_eq!(config.skills, vec!["rust-patterns".to_string()]);
487        assert!(config.system_prompt.contains("senior code reviewer"));
488    }
489
490    #[test]
491    fn test_parse_subagent_inherit_model() {
492        let content = r#"---
493name: explorer
494description: Codebase explorer
495model: inherit
496---
497
498Explore the codebase.
499"#;
500
501        let config = SubagentConfig::from_markdown(content, SubagentSource::Project, None).unwrap();
502        assert_eq!(config.model, SubagentModel::Inherit);
503    }
504
505    #[test]
506    fn test_parse_subagent_json() {
507        let json = serde_json::json!({
508            "description": "Test subagent",
509            "prompt": "You are a test agent.",
510            "tools": ["read_file", "write_file"],
511            "model": "opus"
512        });
513
514        let config = SubagentConfig::from_json("test-agent", &json).unwrap();
515        assert_eq!(config.name, "test-agent");
516        assert_eq!(config.description, "Test subagent");
517        assert_eq!(
518            config.tools,
519            Some(vec!["read_file".to_string(), "write_file".to_string()])
520        );
521        assert_eq!(config.model, SubagentModel::Alias("opus".to_string()));
522    }
523
524    #[test]
525    fn test_tool_access() {
526        let config = SubagentConfig {
527            name: "test".to_string(),
528            description: "test".to_string(),
529            tools: Some(vec!["read_file".to_string(), "grep_file".to_string()]),
530            model: SubagentModel::default(),
531            permission_mode: SubagentPermissionMode::default(),
532            skills: vec![],
533            system_prompt: String::new(),
534            source: SubagentSource::User,
535            file_path: None,
536        };
537
538        assert!(config.has_tool_access("read_file"));
539        assert!(config.has_tool_access("grep_file"));
540        assert!(!config.has_tool_access("write_file"));
541    }
542
543    #[test]
544    fn test_inherit_all_tools() {
545        let config = SubagentConfig {
546            name: "test".to_string(),
547            description: "test".to_string(),
548            tools: None, // Inherit all
549            model: SubagentModel::default(),
550            permission_mode: SubagentPermissionMode::default(),
551            skills: vec![],
552            system_prompt: String::new(),
553            source: SubagentSource::User,
554            file_path: None,
555        };
556
557        assert!(config.has_tool_access("read_file"));
558        assert!(config.has_tool_access("any_tool"));
559    }
560}