claude_agent/subagents/
index_loader.rs

1//! Subagent Index Loader - loads only metadata, defers prompt loading.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use super::SubagentIndex;
8use crate::client::ModelType;
9use crate::common::{ContentSource, SourceType, is_markdown, parse_frontmatter};
10
11/// Frontmatter for subagent files.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SubagentFrontmatter {
14    pub name: String,
15    pub description: String,
16    #[serde(default)]
17    pub tools: Option<String>,
18    #[serde(default)]
19    pub model: Option<String>,
20    #[serde(default)]
21    pub model_type: Option<String>,
22    #[serde(default)]
23    pub skills: Option<String>,
24    #[serde(default, rename = "source-type")]
25    pub source_type: Option<String>,
26}
27
28/// Loader for SubagentIndex - only loads metadata, not full prompt.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct SubagentIndexLoader;
31
32impl SubagentIndexLoader {
33    pub fn new() -> Self {
34        Self
35    }
36
37    /// Parse a subagent file and create an index (metadata only).
38    /// The prompt content is NOT loaded - it will be loaded lazily via ContentSource.
39    pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SubagentIndex> {
40        let doc = parse_frontmatter::<SubagentFrontmatter>(content)?;
41        Ok(self.build_index(doc.frontmatter, path))
42    }
43
44    /// Parse frontmatter only from content, returning the index.
45    pub fn parse_frontmatter_only(
46        &self,
47        content: &str,
48        path: &Path,
49    ) -> crate::Result<SubagentIndex> {
50        self.parse_index(content, path)
51    }
52
53    fn build_index(&self, fm: SubagentFrontmatter, path: &Path) -> SubagentIndex {
54        let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
55
56        let tools: Vec<String> = fm
57            .tools
58            .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
59            .unwrap_or_default();
60
61        let skills: Vec<String> = fm
62            .skills
63            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
64            .unwrap_or_default();
65
66        let mut index = SubagentIndex::new(fm.name, fm.description)
67            .with_source(ContentSource::file(path))
68            .with_source_type(source_type)
69            .with_tools(tools)
70            .with_skills(skills);
71
72        if let Some(model) = fm.model {
73            index = index.with_model(model);
74        }
75
76        if let Some(model_type) = fm.model_type {
77            match model_type.to_lowercase().as_str() {
78                "small" | "haiku" => index = index.with_model_type(ModelType::Small),
79                "primary" | "sonnet" => index = index.with_model_type(ModelType::Primary),
80                "reasoning" | "opus" => index = index.with_model_type(ModelType::Reasoning),
81                _ => {}
82            }
83        }
84
85        index
86    }
87
88    /// Load a subagent index from a file.
89    pub async fn load_file(&self, path: &Path) -> crate::Result<SubagentIndex> {
90        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
91            crate::Error::Config(format!("Failed to read subagent file {:?}: {}", path, e))
92        })?;
93
94        self.parse_index(&content, path)
95    }
96
97    /// Scan a directory for subagent files and create indices.
98    pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SubagentIndex>> {
99        let mut indices = Vec::new();
100
101        if !dir.exists() {
102            return Ok(indices);
103        }
104
105        self.scan_directory_recursive(dir, &mut indices).await?;
106        Ok(indices)
107    }
108
109    fn scan_directory_recursive<'a>(
110        &'a self,
111        dir: &'a Path,
112        indices: &'a mut Vec<SubagentIndex>,
113    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<()>> + Send + 'a>> {
114        Box::pin(async move {
115            let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
116                crate::Error::Config(format!("Failed to read directory {:?}: {}", dir, e))
117            })?;
118
119            while let Some(entry) = entries.next_entry().await.map_err(|e| {
120                crate::Error::Config(format!("Failed to read directory entry: {}", e))
121            })? {
122                let path = entry.path();
123
124                if path.is_dir() {
125                    self.scan_directory_recursive(&path, indices).await?;
126                } else if is_markdown(&path) {
127                    match self.load_file(&path).await {
128                        Ok(index) => indices.push(index),
129                        Err(e) => {
130                            tracing::warn!("Failed to load subagent from {:?}: {}", path, e);
131                        }
132                    }
133                }
134            }
135
136            Ok(())
137        })
138    }
139
140    /// Create an inline subagent index with in-memory content.
141    pub fn create_inline(
142        name: impl Into<String>,
143        description: impl Into<String>,
144        prompt: impl Into<String>,
145    ) -> SubagentIndex {
146        SubagentIndex::new(name, description).with_source(ContentSource::in_memory(prompt))
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_parse_subagent_index() {
156        let content = r#"---
157name: code-reviewer
158description: Expert code reviewer for quality checks
159tools: Read, Grep, Glob
160model: haiku
161---
162
163You are a senior code reviewer focusing on:
164- Code quality and best practices
165- Security vulnerabilities
166"#;
167
168        let loader = SubagentIndexLoader::new();
169        let index = loader
170            .parse_index(content, Path::new("/test/reviewer.md"))
171            .unwrap();
172
173        assert_eq!(index.name, "code-reviewer");
174        assert_eq!(index.description, "Expert code reviewer for quality checks");
175        assert_eq!(index.allowed_tools, vec!["Read", "Grep", "Glob"]);
176        assert_eq!(index.model, Some("haiku".to_string()));
177        // Note: prompt content is NOT loaded here - it's in ContentSource
178        assert!(index.source.is_file());
179    }
180
181    #[test]
182    fn test_parse_subagent_with_skills() {
183        let content = r#"---
184name: full-agent
185description: Full featured agent
186tools: Read, Write, Bash(git:*)
187model: sonnet
188skills: security-check, linting
189---
190
191Full agent prompt.
192"#;
193
194        let loader = SubagentIndexLoader::new();
195        let index = loader
196            .parse_index(content, Path::new("/test/full.md"))
197            .unwrap();
198
199        assert_eq!(index.skills, vec!["security-check", "linting"]);
200        assert_eq!(index.model, Some("sonnet".to_string()));
201    }
202
203    #[test]
204    fn test_create_inline() {
205        let index = SubagentIndexLoader::create_inline(
206            "test-agent",
207            "Test description",
208            "You are a test agent.",
209        );
210
211        assert_eq!(index.name, "test-agent");
212        assert!(index.source.is_in_memory());
213    }
214
215    #[test]
216    fn test_parse_without_frontmatter() {
217        let content = "Just content without frontmatter";
218        let loader = SubagentIndexLoader::new();
219        assert!(loader.parse_index(content, Path::new("/test.md")).is_err());
220    }
221}