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