Skip to main content

oxi_agent/
agent_definition.rs

1//! Agent definition file parsing and validation.
2//!
3//! Loads agent definitions from markdown files with YAML frontmatter.
4//! Discovery searches `~/.oxi/agents/` (user) and `.oxi/agents/` (project).
5//!
6//! Supports two directory layouts (subdirectory takes priority on collision):
7//! - Flat file: `~/.oxi/agents/scout.md`
8//! - Subdirectory: `~/.oxi/agents/scout/agent.md`
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// Agent definition parsed from a markdown file with YAML frontmatter.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AgentDefinition {
19    /// Agent name (a-z, 0-9, hyphens, max 64 chars)
20    pub name: String,
21    /// Human-readable description (max 1024 chars)
22    #[serde(default)]
23    pub description: String,
24    /// Optional model override
25    #[serde(default)]
26    pub model: Option<String>,
27    /// Tool names to make available. Accepts both YAML array and comma-separated string.
28    #[serde(default, deserialize_with = "deserialize_tools")]
29    pub tools: Vec<String>,
30    /// System prompt (from frontmatter or body)
31    #[serde(default)]
32    pub system_prompt: Option<String>,
33    /// Discovery scope: "user" or "project". Set by discovery, not by the file.
34    #[serde(default)]
35    pub source: String,
36    /// Extensions to load
37    #[serde(default)]
38    pub extensions: Vec<String>,
39    /// Maximum subagent nesting depth (max 10)
40    #[serde(default = "default_max_depth")]
41    pub max_subagent_depth: u8,
42    /// Default context mode
43    #[serde(default)]
44    pub default_context: DefaultContext,
45}
46
47fn default_max_depth() -> u8 {
48    3
49}
50
51/// Agent visibility scope for discovery queries.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub enum AgentScope {
54    /// Only user-level agents (~/.oxi/agents/)
55    #[default]
56    User,
57    /// Only project-level agents (.oxi/agents/)
58    Project,
59    /// Both user and project agents
60    Both,
61}
62
63/// Default context for agent sessions.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub enum DefaultContext {
66    #[default]
67    /// Start with an empty context.
68    Fresh,
69    /// Branch from the parent session's context.
70    Fork,
71}
72
73impl AgentDefinition {
74    /// Load an agent definition from a markdown file.
75    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            // No frontmatter — use filename stem as name
83            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        // Use body as system_prompt if not set in frontmatter
106        if !body.is_empty() && def.system_prompt.is_none() {
107            def.system_prompt = Some(body);
108        }
109
110        // If description is still empty, use the first line of the body
111        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    /// Validate the agent definition.
122    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
145/// Custom deserializer for the `tools` field.
146/// Accepts either a YAML array (`["read", "bash"]`) or a comma-separated string (`"read, bash"`).
147fn 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
167/// Validate an agent name.
168pub 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
187/// Extract YAML frontmatter and body from markdown content.
188fn 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
202/// Agent discovery from filesystem directories.
203pub struct AgentDiscovery;
204
205impl AgentDiscovery {
206    /// Discover agent definitions from global and project directories.
207    ///
208    /// Search order (project overrides user on collision):
209    /// 1. Global: ~/.oxi/agents/
210    /// 2. Project: .oxi/agents/ (walks up to .git boundary)
211    ///
212    /// Within each directory, subdirectory format (`<name>/agent.md`) takes
213    /// priority over flat files (`<name>.md`) on name collision.
214    pub fn discover(cwd: &Path, scope: AgentScope) -> Result<Vec<(String, AgentDefinition)>> {
215        let mut agents = HashMap::new();
216
217        // 1. Global: ~/.oxi/agents/
218        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        // 2. Project: walk up to find .oxi/agents/
226        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    /// Discover agents from a single directory.
236    /// Supports both subdirectory format (`<name>/agent.md`) and flat files (`<name>.md`).
237    /// Subdirectory entries are loaded first so they take priority.
238    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        // First pass: subdirectories (higher priority)
248        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        // Second pass: flat .md files (lower priority — or_insert skips collisions)
277        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
306/// Walk up from `cwd` to find `.oxi/agents/`.
307/// Stops at `.git` boundary (project root). Returns None if not found.
308fn 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        // .git marks project root — don't go higher
316        if current.join(".git").exists() {
317            return None;
318        }
319        current = current.parent()?;
320    }
321}
322
323// ── Depth tracking ─────────────────────────────────────────────────────
324
325/// Get the current subagent nesting depth from the environment.
326/// Default is 0 (top-level process).
327pub 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
334/// Get the maximum allowed subagent depth from the environment.
335/// Default is 3.
336pub 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()); // uppercase
360        assert!(validate_agent_name("my_agent").is_err()); // underscore
361        assert!(validate_agent_name(&"a".repeat(65)).is_err()); // too long
362    }
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(); // uppercase
427        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        // Flat file
494        fs::write(
495            agents_dir.join("scout.md"),
496            "---\nname: scout\ndescription: Flat\n---\nFlat scout.",
497        )
498        .unwrap();
499
500        // Subdirectory (should win)
501        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        // Create .git boundary so find_project_agents_dir stops
520        fs::create_dir_all(dir.path().join(".git")).unwrap();
521
522        // Project agent (under cwd/.oxi/agents)
523        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        // Project scope should find project agents
532        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        // Clear env vars to test defaults
560        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}