use std::path::PathBuf;
use serde::Deserialize;
use crate::config::config_dir;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct AgentProfile {
pub name: String,
pub description: String,
pub model: Option<String>,
pub provider: Option<String>,
pub tools: Option<Vec<String>>,
pub max_iterations: Option<usize>,
pub system_prompt: String,
}
#[derive(Debug, Default, Deserialize)]
struct AgentProfileMeta {
#[serde(default)]
description: String,
#[serde(default)]
model: Option<String>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
tools: Option<Vec<String>>,
#[serde(default)]
max_iterations: Option<usize>,
}
#[derive(Clone)]
pub struct SubagentLoader {
agents_dir: PathBuf,
}
impl SubagentLoader {
pub fn new() -> Result<Self> {
let dir = config_dir()?.join("agents");
std::fs::create_dir_all(&dir)?;
Ok(Self { agents_dir: dir })
}
pub fn load(&self) -> Result<Vec<AgentProfile>> {
let mut profiles = Vec::new();
for entry in std::fs::read_dir(&self.agents_dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
file = %path.display(),
error = %e,
"Skipping unreadable subagent profile"
);
continue;
}
};
match parse_profile(name, &content) {
Ok(profile) => profiles.push(profile),
Err(e) => tracing::warn!(
file = %path.display(),
error = %e,
"Skipping invalid subagent profile"
),
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
}
fn parse_profile(name: &str, content: &str) -> Result<AgentProfile> {
let (meta, body) = split_frontmatter(content)?;
Ok(AgentProfile {
name: name.to_string(),
description: meta.description,
model: meta.model,
provider: meta.provider,
tools: meta.tools,
max_iterations: meta.max_iterations,
system_prompt: body.trim().to_string(),
})
}
fn split_frontmatter(content: &str) -> Result<(AgentProfileMeta, &str)> {
let trimmed = content.trim_start();
let Some(rest) = trimmed.strip_prefix("+++") else {
return Ok((AgentProfileMeta::default(), content));
};
let rest = rest.strip_prefix('\n').unwrap_or(rest);
let end = rest
.find("\n+++")
.ok_or_else(|| Error::ParseError("unterminated '+++' frontmatter block".into()))?;
let frontmatter = &rest[..end];
let after_delim = &rest[end + 1..];
let body = match after_delim.find('\n') {
Some(i) => &after_delim[i + 1..],
None => "",
};
let meta: AgentProfileMeta = toml::from_str(frontmatter)
.map_err(|e| Error::ParseError(format!("invalid frontmatter: {e}")))?;
Ok((meta, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_profile_with_frontmatter() {
let content = "+++\n\
description = \"Reviews diffs for correctness and style.\"\n\
model = \"claude-haiku-4-5\"\n\
tools = [\"read_file\"]\n\
max_iterations = 6\n\
+++\n\
You are a meticulous code reviewer.\n";
let profile = parse_profile("code-reviewer", content).unwrap();
assert_eq!(profile.name, "code-reviewer");
assert_eq!(
profile.description,
"Reviews diffs for correctness and style."
);
assert_eq!(profile.model.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(profile.provider, None);
assert_eq!(profile.tools, Some(vec!["read_file".to_string()]));
assert_eq!(profile.max_iterations, Some(6));
assert_eq!(profile.system_prompt, "You are a meticulous code reviewer.");
}
#[test]
fn treats_file_without_frontmatter_as_plain_prompt() {
let content = "You are a helpful research assistant.\nAlways cite sources.";
let profile = parse_profile("researcher", content).unwrap();
assert_eq!(profile.name, "researcher");
assert_eq!(profile.description, "");
assert_eq!(profile.model, None);
assert_eq!(profile.tools, None);
assert_eq!(
profile.system_prompt,
"You are a helpful research assistant.\nAlways cite sources."
);
}
#[test]
fn rejects_unterminated_frontmatter() {
let content = "+++\ndescription = \"oops\"\nNo closing delimiter here.";
let err = parse_profile("broken", content).unwrap_err();
assert!(err.to_string().contains("unterminated"));
}
#[test]
fn rejects_malformed_frontmatter_toml() {
let content = "+++\nthis is not valid toml :::\n+++\nBody.";
let err = parse_profile("broken", content).unwrap_err();
assert!(err.to_string().contains("invalid frontmatter"));
}
#[test]
fn defaults_are_applied_for_partial_frontmatter() {
let content = "+++\ndescription = \"Just a description.\"\n+++\nPrompt body.";
let profile = parse_profile("partial", content).unwrap();
assert_eq!(profile.description, "Just a description.");
assert_eq!(profile.model, None);
assert_eq!(profile.provider, None);
assert_eq!(profile.tools, None);
assert_eq!(profile.max_iterations, None);
assert_eq!(profile.system_prompt, "Prompt body.");
}
}