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