Skip to main content

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