use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use super::frontmatter::parse_kv_line;
const MAX_DEPTH: usize = 8;
#[derive(Debug)]
pub enum AgentBuildError {
NotFound(String),
FrontmatterParse(String),
Cycle(Vec<String>),
DepthExceeded(usize),
Io(std::io::Error),
}
impl fmt::Display for AgentBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound(name) => write!(f, "agent source not found: {name}"),
Self::FrontmatterParse(msg) => write!(f, "frontmatter parse error: {msg}"),
Self::Cycle(chain) => {
write!(f, "inheritance cycle detected: {}", chain.join(" -> "))
}
Self::DepthExceeded(depth) => {
write!(f, "inheritance chain exceeded depth limit of {depth}")
}
Self::Io(err) => write!(f, "io error: {err}"),
}
}
}
impl std::error::Error for AgentBuildError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for AgentBuildError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
pub type SourceMap = HashMap<String, PathBuf>;
pub fn build_source_map(source_dir: &Path) -> SourceMap {
let mut map = SourceMap::new();
let entries = match std::fs::read_dir(source_dir) {
Ok(e) => e,
Err(_) => return map,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
map.insert(stem.to_lowercase(), path);
}
}
map
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Frontmatter {
name: Option<String>,
role: Option<String>,
description: Option<String>,
model: Option<String>,
extends: Option<String>,
}
fn split_frontmatter(raw: &str) -> Result<(Frontmatter, String), AgentBuildError> {
let trimmed_start = raw.trim_start_matches(['\u{feff}']);
let mut lines = trimmed_start.lines();
match lines.next() {
Some(first) if first.trim() == "---" => {}
_ => return Ok((Frontmatter::default(), raw.to_string())),
}
let mut fields: HashMap<String, String> = HashMap::new();
let mut closed = false;
let mut consumed = first_line_len(trimmed_start);
for line in lines {
consumed += line.len() + 1; if line.trim() == "---" {
closed = true;
break;
}
match parse_kv_line(line) {
None if line.trim().is_empty() => continue,
None => {
return Err(AgentBuildError::FrontmatterParse(format!(
"expected `key: value`, got `{line}`"
)));
}
Some((key, value)) => {
fields.insert(key, value);
}
}
}
if !closed {
return Err(AgentBuildError::FrontmatterParse(
"unterminated frontmatter block (missing closing `---`)".to_string(),
));
}
let body = trimmed_start
.get(consumed.min(trimmed_start.len())..)
.unwrap_or("")
.trim_start_matches('\n')
.to_string();
let fm = Frontmatter {
name: fields.remove("name"),
role: fields.remove("role"),
description: fields.remove("description"),
model: fields.remove("model"),
extends: fields.remove("extends"),
};
Ok((fm, body))
}
fn first_line_len(s: &str) -> usize {
match s.find('\n') {
Some(idx) => idx + 1,
None => s.len(),
}
}
fn merge_frontmatter(chain: &[Frontmatter]) -> String {
let mut merged = Frontmatter::default();
for fm in chain {
if fm.name.is_some() {
merged.name = fm.name.clone();
}
if fm.role.is_some() {
merged.role = fm.role.clone();
}
if fm.description.is_some() {
merged.description = fm.description.clone();
}
if fm.model.is_some() {
merged.model = fm.model.clone();
}
}
let mut out = String::from("---\n");
if let Some(v) = &merged.name {
out.push_str(&format!("name: {v}\n"));
}
if let Some(v) = &merged.role {
out.push_str(&format!("role: {v}\n"));
}
if let Some(v) = &merged.description {
out.push_str(&format!("description: {v}\n"));
}
if let Some(v) = &merged.model {
out.push_str(&format!("model: {v}\n"));
}
out.push_str("---\n");
out
}
fn resolve(
name: &str,
sources: &SourceMap,
visiting: &mut Vec<String>,
) -> Result<(Vec<Frontmatter>, Vec<String>), AgentBuildError> {
if visiting.len() >= MAX_DEPTH {
return Err(AgentBuildError::DepthExceeded(MAX_DEPTH));
}
if visiting.iter().any(|n| n == name) {
let mut cycle = visiting.clone();
cycle.push(name.to_string());
return Err(AgentBuildError::Cycle(cycle));
}
let path = sources
.get(&name.to_lowercase())
.ok_or_else(|| AgentBuildError::NotFound(name.to_string()))?;
let raw = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(AgentBuildError::NotFound(name.to_string()));
}
Err(err) => return Err(AgentBuildError::Io(err)),
};
let (fm, body) = split_frontmatter(&raw)?;
visiting.push(name.to_string());
let (mut frontmatters, mut bodies) = match &fm.extends {
Some(parent) => resolve(parent, sources, visiting)?,
None => (Vec::new(), Vec::new()),
};
visiting.pop();
frontmatters.push(fm);
bodies.push(body);
Ok((frontmatters, bodies))
}
pub fn compose_agent(name: &str, source_dir: &Path) -> Result<String, AgentBuildError> {
let sources = build_source_map(source_dir);
let mut visiting = Vec::new();
let (frontmatters, bodies) = resolve(name, &sources, &mut visiting)?;
let mut out = merge_frontmatter(&frontmatters);
out.push('\n');
let joined: Vec<String> = bodies
.iter()
.map(|b| b.trim_matches('\n').to_string())
.filter(|b| !b.is_empty())
.collect();
out.push_str(&joined.join("\n\n"));
out.push('\n');
Ok(out)
}
pub fn source_chain(name: &str, source_dir: &Path) -> Result<Vec<String>, AgentBuildError> {
fn walk(
name: &str,
sources: &SourceMap,
visiting: &mut Vec<String>,
out: &mut Vec<String>,
) -> Result<(), AgentBuildError> {
if visiting.len() >= MAX_DEPTH {
return Err(AgentBuildError::DepthExceeded(MAX_DEPTH));
}
if visiting.iter().any(|n| n == name) {
let mut cycle = visiting.clone();
cycle.push(name.to_string());
return Err(AgentBuildError::Cycle(cycle));
}
let path = sources
.get(&name.to_lowercase())
.ok_or_else(|| AgentBuildError::NotFound(name.to_string()))?;
let raw = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(AgentBuildError::NotFound(name.to_string()));
}
Err(err) => return Err(AgentBuildError::Io(err)),
};
let (fm, _) = split_frontmatter(&raw)?;
visiting.push(name.to_string());
if let Some(parent) = &fm.extends {
walk(parent, sources, visiting, out)?;
}
visiting.pop();
out.push(name.to_string());
Ok(())
}
let sources = build_source_map(source_dir);
let mut chain = Vec::new();
let mut visiting = Vec::new();
walk(name, &sources, &mut visiting, &mut chain)?;
Ok(chain)
}
#[cfg(test)]
#[path = "agent_builder_tests.rs"]
mod tests;