1use serde::{Deserialize, Serialize};
25use std::fmt;
26use std::path::{Path, PathBuf};
27use std::str::FromStr;
28use tracing::debug;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub enum SubagentPermissionMode {
34 #[default]
36 Default,
37 AcceptEdits,
39 BypassPermissions,
41 Plan,
43 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum SubagentModel {
80 Inherit,
82 Alias(String),
84 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub enum SubagentSource {
121 Builtin,
123 User,
125 Project,
127 Cli,
129 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#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct SubagentFrontmatter {
148 pub name: String,
150
151 pub description: String,
153
154 #[serde(default)]
156 pub tools: Option<String>,
157
158 #[serde(default)]
160 pub model: Option<String>,
161
162 #[serde(default, rename = "permissionMode")]
164 pub permission_mode: Option<String>,
165
166 #[serde(default)]
168 pub skills: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct SubagentConfig {
174 pub name: String,
176
177 pub description: String,
179
180 pub tools: Option<Vec<String>>,
182
183 pub model: SubagentModel,
185
186 pub permission_mode: SubagentPermissionMode,
188
189 pub skills: Vec<String>,
191
192 pub system_prompt: String,
194
195 pub source: SubagentSource,
197
198 pub file_path: Option<PathBuf>,
200}
201
202impl SubagentConfig {
203 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 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; let system_prompt = content
229 .get(body_start..)
230 .map(|s| s.trim())
231 .unwrap_or("")
232 .to_string();
233
234 let frontmatter: SubagentFrontmatter =
236 serde_yaml::from_str(yaml_content).map_err(SubagentParseError::YamlError)?;
237
238 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 let model = frontmatter
248 .model
249 .map(|m| SubagentModel::from_str(&m).unwrap())
250 .unwrap_or_default();
251
252 let permission_mode = frontmatter
254 .permission_mode
255 .map(|p| SubagentPermissionMode::from_str(&p).unwrap_or_default())
256 .unwrap_or_default();
257
258 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 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 pub fn has_tool_access(&self, tool_name: &str) -> bool {
350 match &self.tools {
351 None => true, Some(tools) => tools.iter().any(|t| t == tool_name),
353 }
354 }
355
356 pub fn allowed_tools(&self) -> Option<&[String]> {
358 self.tools.as_deref()
359 }
360
361 pub fn is_read_only(&self) -> bool {
363 self.permission_mode == SubagentPermissionMode::Plan
364 }
365}
366
367#[derive(Debug)]
369pub enum SubagentParseError {
370 MissingFrontmatter,
372 YamlError(serde_yaml::Error),
374 MissingField(String),
376 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#[derive(Debug, Clone, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
402pub struct SubagentsConfig {
403 #[serde(default = "default_enabled")]
405 pub enabled: bool,
406
407 #[serde(default = "default_max_concurrent")]
409 pub max_concurrent: usize,
410
411 #[serde(default = "default_timeout_seconds")]
413 pub default_timeout_seconds: u64,
414
415 #[serde(default)]
417 pub default_model: Option<String>,
418
419 #[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 }
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
448pub 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
458pub 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, 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}