use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
pub name: String,
pub description: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub scope: AgentScope,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default = "default_max_depth")]
pub max_subagent_depth: u8,
#[serde(default)]
pub default_context: DefaultContext,
}
fn default_max_depth() -> u8 {
3
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentScope {
#[default]
User,
Project,
Both,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DefaultContext {
#[default]
Fresh,
Fork,
}
impl AgentDefinition {
pub fn from_markdown(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let (frontmatter, body) = extract_frontmatter(&content);
let mut def: AgentDefinition = if frontmatter.is_empty() {
let name = path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
AgentDefinition {
name,
description: String::new(),
model: None,
tools: vec![],
system_prompt: None,
scope: AgentScope::default(),
extensions: vec![],
max_subagent_depth: 3,
default_context: DefaultContext::default(),
}
} else {
serde_yaml::from_str(&frontmatter).with_context(|| {
format!("Failed to parse YAML frontmatter in {}", path.display())
})?
};
if !body.is_empty() && def.system_prompt.is_none() {
def.system_prompt = Some(body);
}
if def.description.is_empty() {
if let Some(first_line) = def.system_prompt.as_ref().and_then(|s| s.lines().next()) {
def.description = first_line.trim_start_matches('#').trim().to_string();
}
}
def.validate()?;
Ok(def)
}
fn validate(&self) -> Result<()> {
validate_agent_name(&self.name)?;
if self.description.len() > 1024 {
anyhow::bail!(
"Description too long ({} chars, max 1024)",
self.description.len()
);
}
if self.max_subagent_depth > 10 {
anyhow::bail!(
"max_subagent_depth too high ({} > 10)",
self.max_subagent_depth
);
}
Ok(())
}
}
pub fn validate_agent_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Agent name must not be empty");
}
if name.len() > 64 {
anyhow::bail!("Agent name too long ({} > 64)", name.len());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
anyhow::bail!(
"Agent name must contain only a-z, 0-9, and hyphens: got '{}'",
name
);
}
Ok(())
}
fn extract_frontmatter(content: &str) -> (String, String) {
let Some(rest) = content.strip_prefix("---") else {
return (String::new(), content.to_string());
};
if let Some(end) = rest.find("\n---") {
let yaml_str = rest[..end].to_string();
let body = rest[end + 4..].trim().to_string();
(yaml_str, body)
} else {
(String::new(), content.to_string())
}
}
pub struct AgentDiscovery;
impl AgentDiscovery {
pub fn discover(cwd: &Path) -> Result<Vec<(String, AgentDefinition)>> {
let mut agents = HashMap::new();
if let Some(home) = dirs::home_dir() {
let global_dir = home.join(".oxi/agents");
Self::discover_from_dir(&global_dir, &mut agents)?;
}
let project_dir = cwd.join(".oxi/agents");
Self::discover_from_dir(&project_dir, &mut agents)?;
Ok(agents.into_iter().collect())
}
fn discover_from_dir(dir: &Path, agents: &mut HashMap<String, AgentDefinition>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let agent_file = path.join("agent.md");
if agent_file.exists() {
let dir_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match AgentDefinition::from_markdown(&agent_file) {
Ok(def) => {
agents.insert(dir_name.to_lowercase(), def);
}
Err(e) => {
tracing::warn!(
"Failed to load agent from {}: {}",
agent_file.display(),
e
);
}
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_validate_agent_name_valid() {
assert!(validate_agent_name("my-agent").is_ok());
assert!(validate_agent_name("agent123").is_ok());
assert!(validate_agent_name("a").is_ok());
}
#[test]
fn test_validate_agent_name_invalid() {
assert!(validate_agent_name("").is_err());
assert!(validate_agent_name("Agent").is_err()); assert!(validate_agent_name("my_agent").is_err()); assert!(validate_agent_name(&"a".repeat(65)).is_err()); }
#[test]
fn test_extract_frontmatter() {
let content = "---\nname: test-agent\ndescription: A test\n---\nBody content";
let (fm, body) = extract_frontmatter(content);
assert!(fm.contains("test-agent"));
assert!(body.starts_with("Body content"));
}
#[test]
fn test_extract_frontmatter_none() {
let content = "# No frontmatter\nJust content";
let (fm, body) = extract_frontmatter(content);
assert!(fm.is_empty());
assert!(body.contains("No frontmatter"));
}
#[test]
fn test_from_markdown_with_frontmatter() {
let dir = TempDir::new().unwrap();
let agent_dir = dir.path().join("test-agent");
fs::create_dir_all(&agent_dir).unwrap();
let agent_file = agent_dir.join("agent.md");
let mut f = fs::File::create(&agent_file).unwrap();
writeln!(f, "---").unwrap();
writeln!(f, "name: test-agent").unwrap();
writeln!(f, "description: A test agent").unwrap();
writeln!(f, "model: gpt-4o").unwrap();
writeln!(f, "tools:").unwrap();
writeln!(f, " - read").unwrap();
writeln!(f, " - bash").unwrap();
writeln!(f, "max_subagent_depth: 5").unwrap();
writeln!(f, "---").unwrap();
writeln!(f, "You are a test agent.").unwrap();
let def = AgentDefinition::from_markdown(&agent_file).unwrap();
assert_eq!(def.name, "test-agent");
assert_eq!(def.description, "A test agent");
assert_eq!(def.model, Some("gpt-4o".to_string()));
assert_eq!(def.tools, vec!["read", "bash"]);
assert_eq!(def.max_subagent_depth, 5);
assert_eq!(def.system_prompt, Some("You are a test agent.".to_string()));
}
#[test]
fn test_from_markdown_validation_fails() {
let dir = TempDir::new().unwrap();
let agent_dir = dir.path().join("bad-agent");
fs::create_dir_all(&agent_dir).unwrap();
let agent_file = agent_dir.join("agent.md");
let mut f = fs::File::create(&agent_file).unwrap();
writeln!(f, "---").unwrap();
writeln!(f, "name: BAD_NAME").unwrap(); writeln!(f, "description: Invalid").unwrap();
writeln!(f, "---").unwrap();
let result = AgentDefinition::from_markdown(&agent_file);
assert!(result.is_err());
}
#[test]
fn test_discover() {
let dir = TempDir::new().unwrap();
let agents_dir = dir.path().join(".oxi/agents");
let agent_dir = agents_dir.join("my-worker");
fs::create_dir_all(&agent_dir).unwrap();
let agent_file = agent_dir.join("agent.md");
let mut f = fs::File::create(&agent_file).unwrap();
writeln!(f, "---").unwrap();
writeln!(f, "name: my-worker").unwrap();
writeln!(f, "description: Worker agent").unwrap();
writeln!(f, "---").unwrap();
writeln!(f, "You are a worker.").unwrap();
let agents = AgentDiscovery::discover(dir.path()).unwrap();
assert_eq!(agents.len(), 1);
let (name, def) = &agents[0];
assert_eq!(name, "my-worker");
assert_eq!(def.name, "my-worker");
}
}