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)]
354pub enum SubagentParseError {
355    /// Missing YAML frontmatter
356    MissingFrontmatter,
357    /// YAML parsing error
358    YamlError(serde_yaml::Error),
359    /// Missing required field
360    MissingField(String),
361    /// IO error when reading file
362    IoError(std::io::Error),
363}
364
365impl fmt::Display for SubagentParseError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        match self {
368            Self::MissingFrontmatter => write!(f, "Missing YAML frontmatter (---...---)"),
369            Self::YamlError(e) => write!(f, "YAML parse error: {}", e),
370            Self::MissingField(field) => write!(f, "Missing required field: {}", field),
371            Self::IoError(e) => write!(f, "IO error: {}", e),
372        }
373    }
374}
375
376impl std::error::Error for SubagentParseError {}
377
378impl From<std::io::Error> for SubagentParseError {
379    fn from(e: std::io::Error) -> Self {
380        Self::IoError(e)
381    }
382}
383
384/// Configuration for the subagent system in vtcode.toml
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387pub struct SubagentsConfig {
388    /// Enable the subagent system
389    #[serde(default = "default_enabled")]
390    pub enabled: bool,
391
392    /// Maximum concurrent subagents
393    #[serde(default = "default_max_concurrent")]
394    pub max_concurrent: usize,
395
396    /// Default timeout for subagent execution (seconds)
397    #[serde(default = "default_timeout_seconds")]
398    pub default_timeout_seconds: u64,
399
400    /// Default model for subagents (if not specified in subagent config)
401    #[serde(default)]
402    pub default_model: Option<String>,
403
404    /// Additional directories to search for subagent definitions
405    #[serde(default)]
406    pub additional_agent_dirs: Vec<PathBuf>,
407}
408
409fn default_enabled() -> bool {
410    true
411}
412
413fn default_max_concurrent() -> usize {
414    3
415}
416
417fn default_timeout_seconds() -> u64 {
418    300 // 5 minutes
419}
420
421impl Default for SubagentsConfig {
422    fn default() -> Self {
423        Self {
424            enabled: default_enabled(),
425            max_concurrent: default_max_concurrent(),
426            default_timeout_seconds: default_timeout_seconds(),
427            default_model: None,
428            additional_agent_dirs: Vec::new(),
429        }
430    }
431}
432
433/// Load subagent from a markdown file
434pub fn load_subagent_from_file(
435    path: &Path,
436    source: SubagentSource,
437) -> Result<SubagentConfig, SubagentParseError> {
438    let content = std::fs::read_to_string(path)?;
439    SubagentConfig::from_markdown(&content, source, Some(path.to_path_buf()))
440}
441
442/// Discover all subagent files in a directory
443pub fn discover_subagents_in_dir(
444    dir: &Path,
445    source: SubagentSource,
446) -> Vec<Result<SubagentConfig, SubagentParseError>> {
447    let mut results = Vec::new();
448
449    if !dir.exists() || !dir.is_dir() {
450        return results;
451    }
452
453    if let Ok(entries) = std::fs::read_dir(dir) {
454        for entry in entries.flatten() {
455            let path = entry.path();
456            if path.extension().map(|e| e == "md").unwrap_or(false) {
457                results.push(load_subagent_from_file(&path, source.clone()));
458            }
459        }
460    }
461
462    results
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use std::path::Path;
469
470    #[test]
471    fn test_parse_subagent_markdown() {
472        let content = r#"---
473name: code-reviewer
474description: Expert code reviewer for quality and security
475tools: read_file, grep_file, list_files
476model: sonnet
477permissionMode: default
478skills: rust-patterns
479---
480
481You are a senior code reviewer.
482Focus on quality, security, and best practices.
483"#;
484
485        let config = SubagentConfig::from_markdown(content, SubagentSource::User, None).unwrap();
486
487        assert_eq!(config.name, "code-reviewer");
488        assert_eq!(
489            config.description,
490            "Expert code reviewer for quality and security"
491        );
492        assert_eq!(
493            config.tools,
494            Some(vec![
495                "read_file".to_string(),
496                "grep_file".to_string(),
497                "list_files".to_string()
498            ])
499        );
500        assert_eq!(config.model, SubagentModel::Alias("sonnet".to_string()));
501        assert_eq!(config.permission_mode, SubagentPermissionMode::Default);
502        assert_eq!(config.skills, vec!["rust-patterns".to_string()]);
503        assert!(config.system_prompt.contains("senior code reviewer"));
504    }
505
506    #[test]
507    fn test_parse_subagent_inherit_model() {
508        let content = r#"---
509name: explorer
510description: Codebase explorer
511model: inherit
512---
513
514Explore the codebase.
515"#;
516
517        let config = SubagentConfig::from_markdown(content, SubagentSource::Project, None).unwrap();
518        assert_eq!(config.model, SubagentModel::Inherit);
519    }
520
521    #[test]
522    fn test_parse_subagent_json() {
523        let json = serde_json::json!({
524            "description": "Test subagent",
525            "prompt": "You are a test agent.",
526            "tools": ["read_file", "write_file"],
527            "model": "opus"
528        });
529
530        let config = SubagentConfig::from_json("test-agent", &json).unwrap();
531        assert_eq!(config.name, "test-agent");
532        assert_eq!(config.description, "Test subagent");
533        assert_eq!(
534            config.tools,
535            Some(vec!["read_file".to_string(), "write_file".to_string()])
536        );
537        assert_eq!(config.model, SubagentModel::Alias("opus".to_string()));
538    }
539
540    #[test]
541    fn test_tool_access() {
542        let config = SubagentConfig {
543            name: "test".to_string(),
544            description: "test".to_string(),
545            tools: Some(vec!["read_file".to_string(), "grep_file".to_string()]),
546            model: SubagentModel::default(),
547            permission_mode: SubagentPermissionMode::default(),
548            skills: vec![],
549            system_prompt: String::new(),
550            source: SubagentSource::User,
551            file_path: None,
552        };
553
554        assert!(config.has_tool_access("read_file"));
555        assert!(config.has_tool_access("grep_file"));
556        assert!(!config.has_tool_access("write_file"));
557    }
558
559    #[test]
560    fn test_inherit_all_tools() {
561        let config = SubagentConfig {
562            name: "test".to_string(),
563            description: "test".to_string(),
564            tools: None, // Inherit all
565            model: SubagentModel::default(),
566            permission_mode: SubagentPermissionMode::default(),
567            skills: vec![],
568            system_prompt: String::new(),
569            source: SubagentSource::User,
570            file_path: None,
571        };
572
573        assert!(config.has_tool_access("read_file"));
574        assert!(config.has_tool_access("any_tool"));
575    }
576}