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