1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AgentDefinition {
21 pub name: String,
23 #[serde(default)]
25 pub description: String,
26 #[serde(default)]
28 pub model: Option<String>,
29 #[serde(default, deserialize_with = "deserialize_tools")]
31 pub tools: Vec<String>,
32 #[serde(default)]
34 pub system_prompt: Option<String>,
35 #[serde(default)]
37 pub source: String,
38 #[serde(default)]
40 pub extensions: Vec<String>,
41 #[serde(default = "default_max_depth")]
43 pub max_subagent_depth: u8,
44 #[serde(default)]
46 pub default_context: DefaultContext,
47}
48
49fn default_max_depth() -> u8 {
50 3
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub enum AgentScope {
56 #[default]
58 User,
59 Project,
61 Both,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
67pub enum DefaultContext {
68 #[default]
69 Fresh,
71 Fork,
73}
74
75impl AgentDefinition {
76 pub fn from_markdown(path: &Path) -> Result<Self> {
78 let content = fs::read_to_string(path)
79 .with_context(|| format!("Failed to read {}", path.display()))?;
80
81 let (frontmatter, body) = extract_frontmatter(&content);
82
83 let mut def: AgentDefinition = if frontmatter.is_empty() {
84 let name = path
86 .file_stem()
87 .and_then(|s| s.to_str())
88 .map(|s| s.to_string())
89 .unwrap_or_default();
90 AgentDefinition {
91 name,
92 description: String::new(),
93 model: None,
94 tools: vec![],
95 system_prompt: None,
96 source: String::new(),
97 extensions: vec![],
98 max_subagent_depth: 3,
99 default_context: DefaultContext::default(),
100 }
101 } else {
102 serde_yaml::from_str(&frontmatter).with_context(|| {
103 format!("Failed to parse YAML frontmatter in {}", path.display())
104 })?
105 };
106
107 if !body.is_empty() && def.system_prompt.is_none() {
109 def.system_prompt = Some(body);
110 }
111
112 if def.description.is_empty()
114 && let Some(first_line) = def.system_prompt.as_ref().and_then(|s| s.lines().next())
115 {
116 def.description = first_line.trim_start_matches('#').trim().to_string();
117 }
118
119 def.validate()?;
120 Ok(def)
121 }
122
123 fn validate(&self) -> Result<()> {
125 validate_agent_name(&self.name)?;
126
127 if self.description.len() > 1024 {
128 anyhow::bail!(
129 "Description too long ({} chars, max 1024)",
130 self.description.len()
131 );
132 }
133
134 if self.max_subagent_depth > 10 {
135 anyhow::bail!(
136 "max_subagent_depth too high ({} > 10)",
137 self.max_subagent_depth
138 );
139 }
140
141 Ok(())
142 }
143}
144
145use serde::de::Deserializer;
146
147fn deserialize_tools<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
150where
151 D: Deserializer<'de>,
152{
153 use serde_yaml::Value;
154 let value = Value::deserialize(deserializer)?;
155 match value {
156 Value::Sequence(seq) => Ok(seq
157 .into_iter()
158 .filter_map(|v| v.as_str().map(String::from))
159 .collect()),
160 Value::String(s) => Ok(s
161 .split(',')
162 .map(|t| t.trim().to_string())
163 .filter(|t| !t.is_empty())
164 .collect()),
165 _ => Ok(vec![]),
166 }
167}
168
169pub fn validate_agent_name(name: &str) -> Result<()> {
171 if name.is_empty() {
172 anyhow::bail!("Agent name must not be empty");
173 }
174 if name.len() > 64 {
175 anyhow::bail!("Agent name too long ({} > 64)", name.len());
176 }
177 if !name
178 .chars()
179 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
180 {
181 anyhow::bail!(
182 "Agent name must contain only a-z, 0-9, and hyphens: got '{}'",
183 name
184 );
185 }
186 Ok(())
187}
188
189fn extract_frontmatter(content: &str) -> (String, String) {
191 let Some(rest) = content.strip_prefix("---") else {
192 return (String::new(), content.to_string());
193 };
194
195 if let Some(end) = rest.find("\n---") {
196 let yaml_str = rest[..end].to_string();
197 let body = rest[end + 4..].trim().to_string();
198 (yaml_str, body)
199 } else {
200 (String::new(), content.to_string())
201 }
202}
203
204pub struct AgentDiscovery;
206
207impl AgentDiscovery {
208 pub fn discover(cwd: &Path, scope: AgentScope) -> Result<Vec<(String, AgentDefinition)>> {
218 let mut agents = HashMap::new();
219
220 if (scope == AgentScope::User || scope == AgentScope::Both)
222 && let Some(global_dir) = oxicode_ai::oxi_home::read_path(Path::new("agents"))
223 {
224 Self::discover_from_dir(&global_dir, "user", &mut agents)?;
225 }
226
227 if (scope == AgentScope::Project || scope == AgentScope::Both)
229 && let Some(project_dir) = find_project_agents_dir(cwd)
230 {
231 Self::discover_from_dir(&project_dir, "project", &mut agents)?;
232 }
233
234 Ok(agents.into_iter().collect())
235 }
236
237 fn discover_from_dir(
241 dir: &Path,
242 source: &str,
243 agents: &mut HashMap<String, AgentDefinition>,
244 ) -> Result<()> {
245 if !dir.is_dir() {
246 return Ok(());
247 }
248
249 for entry in fs::read_dir(dir)? {
251 let entry = entry?;
252 let path = entry.path();
253
254 if path.is_dir() {
255 let agent_file = path.join("agent.md");
256 if agent_file.exists() {
257 let dir_name = path
258 .file_name()
259 .map(|n| n.to_string_lossy().to_string())
260 .unwrap_or_default();
261 match AgentDefinition::from_markdown(&agent_file) {
262 Ok(mut def) => {
263 def.source = source.to_string();
264 agents.insert(dir_name.to_lowercase(), def);
265 }
266 Err(e) => {
267 tracing::warn!(
268 "Failed to load agent from {}: {}",
269 agent_file.display(),
270 e
271 );
272 }
273 }
274 }
275 }
276 }
277
278 for entry in fs::read_dir(dir)? {
280 let entry = entry?;
281 let path = entry.path();
282
283 if !path.is_dir() && path.extension().and_then(|e| e.to_str()) == Some("md") {
284 let name = path
285 .file_stem()
286 .and_then(|s| s.to_str())
287 .unwrap_or("")
288 .to_string();
289 if name.is_empty() {
290 continue;
291 }
292 match AgentDefinition::from_markdown(&path) {
293 Ok(mut def) => {
294 def.source = source.to_string();
295 agents.entry(name.to_lowercase()).or_insert(def);
296 }
297 Err(e) => {
298 tracing::warn!("Failed to load agent {}: {}", path.display(), e);
299 }
300 }
301 }
302 }
303
304 Ok(())
305 }
306}
307
308fn find_project_agents_dir(cwd: &Path) -> Option<PathBuf> {
311 let mut current = cwd;
312 loop {
313 let candidate = current.join(".oxicode").join("agents");
314 if candidate.is_dir() {
315 return Some(candidate);
316 }
317 if current.join(".git").exists() {
319 return None;
320 }
321 current = current.parent()?;
322 }
323}
324
325pub fn current_subagent_depth() -> u8 {
330 std::env::var("OXICODE_SUBAGENT_DEPTH")
331 .ok()
332 .and_then(|v| v.parse().ok())
333 .unwrap_or(0)
334}
335
336pub fn max_subagent_depth() -> u8 {
339 std::env::var("OXICODE_MAX_SUBAGENT_DEPTH")
340 .ok()
341 .and_then(|v| v.parse().ok())
342 .unwrap_or(3)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use std::io::Write;
349 use tempfile::TempDir;
350
351 #[test]
352 fn test_validate_agent_name_valid() {
353 assert!(validate_agent_name("my-agent").is_ok());
354 assert!(validate_agent_name("agent123").is_ok());
355 assert!(validate_agent_name("a").is_ok());
356 }
357
358 #[test]
359 fn test_validate_agent_name_invalid() {
360 assert!(validate_agent_name("").is_err());
361 assert!(validate_agent_name("Agent").is_err()); assert!(validate_agent_name("my_agent").is_err()); assert!(validate_agent_name(&"a".repeat(65)).is_err()); }
365
366 #[test]
367 fn test_extract_frontmatter() {
368 let content = "---\nname: test-agent\ndescription: A test\n---\nBody content";
369 let (fm, body) = extract_frontmatter(content);
370 assert!(fm.contains("test-agent"));
371 assert!(body.starts_with("Body content"));
372 }
373
374 #[test]
375 fn test_extract_frontmatter_none() {
376 let content = "# No frontmatter\nJust content";
377 let (fm, body) = extract_frontmatter(content);
378 assert!(fm.is_empty());
379 assert!(body.contains("No frontmatter"));
380 }
381
382 #[test]
383 fn test_from_markdown_with_frontmatter() {
384 let dir = TempDir::new().unwrap();
385 let agent_file = dir.path().join("test-agent.md");
386 let mut f = fs::File::create(&agent_file).unwrap();
387 writeln!(f, "---").unwrap();
388 writeln!(f, "name: test-agent").unwrap();
389 writeln!(f, "description: A test agent").unwrap();
390 writeln!(f, "model: gpt-4o").unwrap();
391 writeln!(f, "tools:").unwrap();
392 writeln!(f, " - read").unwrap();
393 writeln!(f, " - bash").unwrap();
394 writeln!(f, "max_subagent_depth: 5").unwrap();
395 writeln!(f, "---").unwrap();
396 writeln!(f, "You are a test agent.").unwrap();
397
398 let def = AgentDefinition::from_markdown(&agent_file).unwrap();
399 assert_eq!(def.name, "test-agent");
400 assert_eq!(def.description, "A test agent");
401 assert_eq!(def.model, Some("gpt-4o".to_string()));
402 assert_eq!(def.tools, vec!["read", "bash"]);
403 assert_eq!(def.max_subagent_depth, 5);
404 assert_eq!(def.system_prompt, Some("You are a test agent.".to_string()));
405 }
406
407 #[test]
408 fn test_from_markdown_flat_tools() {
409 let dir = TempDir::new().unwrap();
410 let agent_file = dir.path().join("scout.md");
411 let mut f = fs::File::create(&agent_file).unwrap();
412 writeln!(f, "---").unwrap();
413 writeln!(f, "name: scout").unwrap();
414 writeln!(f, "tools: read, grep, find").unwrap();
415 writeln!(f, "---").unwrap();
416 writeln!(f, "You are a scout.").unwrap();
417
418 let def = AgentDefinition::from_markdown(&agent_file).unwrap();
419 assert_eq!(def.tools, vec!["read", "grep", "find"]);
420 }
421
422 #[test]
423 fn test_from_markdown_validation_fails() {
424 let dir = TempDir::new().unwrap();
425 let agent_file = dir.path().join("bad.md");
426 let mut f = fs::File::create(&agent_file).unwrap();
427 writeln!(f, "---").unwrap();
428 writeln!(f, "name: BAD_NAME").unwrap(); writeln!(f, "description: Invalid").unwrap();
430 writeln!(f, "---").unwrap();
431
432 let result = AgentDefinition::from_markdown(&agent_file);
433 assert!(result.is_err());
434 }
435
436 #[test]
437 fn test_from_markdown_no_frontmatter() {
438 let dir = TempDir::new().unwrap();
439 let agent_file = dir.path().join("worker.md");
440 fs::write(&agent_file, "You are a worker agent.").unwrap();
441
442 let def = AgentDefinition::from_markdown(&agent_file).unwrap();
443 assert_eq!(def.name, "worker");
444 assert_eq!(
445 def.system_prompt,
446 Some("You are a worker agent.".to_string())
447 );
448 }
449
450 #[test]
451 fn test_discover_subdirectory() {
452 let dir = TempDir::new().unwrap();
453 let agents_dir = dir.path().join(".oxicode").join("agents");
454 let agent_dir = agents_dir.join("my-worker");
455 fs::create_dir_all(&agent_dir).unwrap();
456 let agent_file = agent_dir.join("agent.md");
457 let mut f = fs::File::create(&agent_file).unwrap();
458 writeln!(f, "---").unwrap();
459 writeln!(f, "name: my-worker").unwrap();
460 writeln!(f, "description: Worker agent").unwrap();
461 writeln!(f, "---").unwrap();
462 writeln!(f, "You are a worker.").unwrap();
463
464 let agents = AgentDiscovery::discover(dir.path(), AgentScope::Project).unwrap();
465 assert_eq!(agents.len(), 1);
466 let (name, def) = &agents[0];
467 assert_eq!(name, "my-worker");
468 assert_eq!(def.name, "my-worker");
469 assert_eq!(def.source, "project");
470 }
471
472 #[test]
473 fn test_discover_flat_md() {
474 let dir = TempDir::new().unwrap();
475 let agents_dir = dir.path().join(".oxicode").join("agents");
476 fs::create_dir_all(&agents_dir).unwrap();
477 fs::write(
478 agents_dir.join("scout.md"),
479 "---\nname: scout\ndescription: Recon\n---\nBe a scout.",
480 )
481 .unwrap();
482
483 let agents = AgentDiscovery::discover(dir.path(), AgentScope::Project).unwrap();
484 assert_eq!(agents.len(), 1);
485 let (name, _) = &agents[0];
486 assert_eq!(name, "scout");
487 }
488
489 #[test]
490 fn test_discover_subdir_takes_priority() {
491 let dir = TempDir::new().unwrap();
492 let agents_dir = dir.path().join(".oxicode").join("agents");
493 fs::create_dir_all(&agents_dir).unwrap();
494
495 fs::write(
497 agents_dir.join("scout.md"),
498 "---\nname: scout\ndescription: Flat\n---\nFlat scout.",
499 )
500 .unwrap();
501
502 let subdir = agents_dir.join("scout");
504 fs::create_dir_all(&subdir).unwrap();
505 fs::write(
506 subdir.join("agent.md"),
507 "---\nname: scout\ndescription: Subdir\n---\nSubdir scout.",
508 )
509 .unwrap();
510
511 let agents = AgentDiscovery::discover(dir.path(), AgentScope::Project).unwrap();
512 assert_eq!(agents.len(), 1);
513 let (_, def) = &agents[0];
514 assert_eq!(def.description, "Subdir");
515 }
516
517 #[test]
518 fn test_discover_scope_filtering() {
519 let dir = TempDir::new().unwrap();
520
521 fs::create_dir_all(dir.path().join(".git")).unwrap();
523
524 let agents_dir = dir.path().join(".oxicode").join("agents");
526 fs::create_dir_all(&agents_dir).unwrap();
527 fs::write(
528 agents_dir.join("project-agent.md"),
529 "---\nname: project-agent\n---\nProject.",
530 )
531 .unwrap();
532
533 let agents = AgentDiscovery::discover(dir.path(), AgentScope::Project).unwrap();
535 assert_eq!(agents.len(), 1);
536 assert_eq!(agents[0].1.source, "project");
537 }
538
539 #[test]
540 fn test_find_project_agents_dir() {
541 let dir = TempDir::new().unwrap();
542 let agents_dir = dir.path().join(".oxicode").join("agents");
543 fs::create_dir_all(&agents_dir).unwrap();
544 let git_dir = dir.path().join(".git");
545 fs::create_dir_all(&git_dir).unwrap();
546 let sub = dir.path().join("subdir");
547 fs::create_dir_all(&sub).unwrap();
548 assert_eq!(find_project_agents_dir(&sub), Some(agents_dir));
549 }
550
551 #[test]
552 fn test_find_project_agents_dir_stops_at_git() {
553 let dir = TempDir::new().unwrap();
554 let git_dir = dir.path().join(".git");
555 fs::create_dir_all(&git_dir).unwrap();
556 assert_eq!(find_project_agents_dir(dir.path()), None);
557 }
558
559 #[test]
560 fn test_depth_functions_default() {
561 unsafe {
563 std::env::remove_var("OXICODE_SUBAGENT_DEPTH");
564 std::env::remove_var("OXICODE_MAX_SUBAGENT_DEPTH");
565 }
566 assert_eq!(current_subagent_depth(), 0);
567 assert_eq!(max_subagent_depth(), 3);
568 }
569}