1use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::str::FromStr;
17use tracing::debug;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum SubagentPermissionMode {
23 #[default]
25 Default,
26 AcceptEdits,
28 BypassPermissions,
30 Plan,
32 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(untagged)]
68pub enum SubagentModel {
69 Inherit,
71 Alias(String),
73 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum SubagentSource {
110 Builtin,
112 User,
114 Project,
116 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#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SubagentFrontmatter {
134 pub name: String,
136
137 pub description: String,
139
140 #[serde(default)]
142 pub tools: Option<String>,
143
144 #[serde(default)]
146 pub model: Option<String>,
147
148 #[serde(default, rename = "permissionMode")]
150 pub permission_mode: Option<String>,
151
152 #[serde(default)]
154 pub skills: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SubagentConfig {
160 pub name: String,
162
163 pub description: String,
165
166 pub tools: Option<Vec<String>>,
168
169 pub model: SubagentModel,
171
172 pub permission_mode: SubagentPermissionMode,
174
175 pub skills: Vec<String>,
177
178 pub system_prompt: String,
180
181 pub source: SubagentSource,
183
184 pub file_path: Option<PathBuf>,
186}
187
188impl SubagentConfig {
189 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 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; let system_prompt = content
215 .get(body_start..)
216 .map(|s| s.trim())
217 .unwrap_or("")
218 .to_string();
219
220 let frontmatter: SubagentFrontmatter =
222 serde_yaml::from_str(yaml_content).map_err(SubagentParseError::YamlError)?;
223
224 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 let model = frontmatter
234 .model
235 .map(|m| SubagentModel::from_str(&m).unwrap())
236 .unwrap_or_default();
237
238 let permission_mode = frontmatter
240 .permission_mode
241 .map(|p| SubagentPermissionMode::from_str(&p).unwrap_or_default())
242 .unwrap_or_default();
243
244 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 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, file_path: None,
330 })
331 }
332
333 pub fn has_tool_access(&self, tool_name: &str) -> bool {
335 match &self.tools {
336 None => true, Some(tools) => tools.iter().any(|t| t == tool_name),
338 }
339 }
340
341 pub fn allowed_tools(&self) -> Option<&[String]> {
343 self.tools.as_deref()
344 }
345
346 pub fn is_read_only(&self) -> bool {
348 self.permission_mode == SubagentPermissionMode::Plan
349 }
350}
351
352#[derive(Debug)]
354pub enum SubagentParseError {
355 MissingFrontmatter,
357 YamlError(serde_yaml::Error),
359 MissingField(String),
361 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#[derive(Debug, Clone, Serialize, Deserialize)]
386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387pub struct SubagentsConfig {
388 #[serde(default = "default_enabled")]
390 pub enabled: bool,
391
392 #[serde(default = "default_max_concurrent")]
394 pub max_concurrent: usize,
395
396 #[serde(default = "default_timeout_seconds")]
398 pub default_timeout_seconds: u64,
399
400 #[serde(default)]
402 pub default_model: Option<String>,
403
404 #[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 }
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
433pub 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
442pub 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, 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}