use std::path::Path;
use super::frontmatter::parse_kv_line;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSummary {
pub name: String,
pub role: String,
pub description: Option<String>,
pub model: Option<String>,
pub extends_chain: Vec<String>,
}
const MANIFEST_FILE: &str = "manifest.json";
#[derive(Debug, Default)]
struct AgentFrontmatter {
name: Option<String>,
role: Option<String>,
description: Option<String>,
model: Option<String>,
extends: Option<String>,
}
fn parse_frontmatter(raw: &str) -> AgentFrontmatter {
let trimmed = raw.trim_start_matches(['\u{feff}']);
let mut lines = trimmed.lines();
match lines.next() {
Some(first) if first.trim() == "---" => {}
_ => return AgentFrontmatter::default(),
}
let mut fm = AgentFrontmatter::default();
for line in lines {
if line.trim() == "---" {
break;
}
let Some((key, value)) = parse_kv_line(line) else {
continue;
};
if value.is_empty() {
continue;
}
match key.as_str() {
"name" => fm.name = Some(value),
"role" => fm.role = Some(value),
"description" => fm.description = Some(value),
"model" => fm.model = Some(value),
"extends" => fm.extends = Some(value),
_ => {}
}
}
fm
}
fn is_base_role(role: &str) -> bool {
role.trim().to_ascii_lowercase().starts_with("base")
}
pub fn scan_agents(agents_dir: &Path) -> Vec<AgentSummary> {
let Ok(entries) = std::fs::read_dir(agents_dir) else {
return Vec::new();
};
let mut summaries: Vec<AgentSummary> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if file_name.eq_ignore_ascii_case(MANIFEST_FILE) {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if stem.to_ascii_lowercase().starts_with("base") {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(raw) = std::fs::read_to_string(&path) else {
continue;
};
let fm = parse_frontmatter(&raw);
let name = fm.name.clone().unwrap_or_else(|| stem.to_string());
let role = fm.role.clone().unwrap_or_else(|| name.clone());
if is_base_role(&role) {
continue;
}
let extends_chain = build_extends_chain(fm.extends.as_deref(), &name);
summaries.push(AgentSummary {
name,
role,
description: fm.description,
model: fm.model,
extends_chain,
});
}
summaries.sort_by_key(|a| a.name.clone());
summaries
}
fn build_extends_chain(extends: Option<&str>, name: &str) -> Vec<String> {
match extends {
Some(parent) if !parent.is_empty() => {
vec![parent.to_string(), name.to_string()]
}
_ => vec![name.to_string()],
}
}
pub fn generate_authority(agents: &[AgentSummary]) -> String {
let mut out = String::from("## Delegation Authority\n\n");
if agents.is_empty() {
out.push_str(
"No delegatable agents are currently available. Handle all work \
directly until agents are deployed.\n",
);
return out;
}
out.push_str(
"The following agents are available for delegation. Route work to the\n\
appropriate agent based on task type.\n\n",
);
for agent in agents {
out.push_str(&format!("### {}\n", agent.name));
out.push_str(&format!("- **Role:** {}\n", agent.role));
let handles = agent
.description
.as_deref()
.unwrap_or("(no description provided)");
out.push_str(&format!("- **Handles:** {handles}\n"));
out.push_str(&format!(
"- **Foundation:** {}\n",
agent.extends_chain.join(" → ")
));
if let Some(model) = &agent.model {
out.push_str(&format!("- **Model:** {model}\n"));
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_agent(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(format!("{name}.md")), content).expect("write agent");
}
#[test]
fn scan_finds_agents() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"engineer",
"---\nname: engineer\nrole: engineer\nextends: base-engineer\n\
description: Implements features and fixes bugs.\nmodel: sonnet\n---\n\n# Engineer\n",
);
write_agent(
tmp.path(),
"BASE-AGENT",
"---\nname: base-agent\nrole: base\n---\n\n# Base\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1, "only the engineer is deployable");
let engineer = &agents[0];
assert_eq!(engineer.name, "engineer");
assert_eq!(engineer.role, "engineer");
assert_eq!(
engineer.description.as_deref(),
Some("Implements features and fixes bugs.")
);
assert_eq!(engineer.model.as_deref(), Some("sonnet"));
assert_eq!(
engineer.extends_chain,
vec!["base-engineer".to_string(), "engineer".to_string()]
);
}
#[test]
fn scan_excludes_base_agents() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"BASE-AGENT",
"---\nname: base-agent\nrole: base\n---\n\nfoundation\n",
);
write_agent(
tmp.path(),
"base-engineer",
"---\nname: base-engineer\nrole: base-engineer\n---\n\nfoundation\n",
);
write_agent(
tmp.path(),
"foundation",
"---\nname: foundation\nrole: base-thing\n---\n\nfoundation\n",
);
write_agent(
tmp.path(),
"qa",
"---\nname: qa\nrole: qa\ndescription: Tests things.\n---\n\n# QA\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "qa");
assert!(
agents.iter().all(|a| !a.role.starts_with("base")),
"no base-role agent should survive the scan"
);
}
#[test]
fn scan_empty_dir() {
let tmp = TempDir::new().unwrap();
let agents = scan_agents(tmp.path());
assert!(agents.is_empty());
}
#[test]
fn scan_missing_dir_is_empty() {
let agents = scan_agents(Path::new("/no/such/agents/dir/xyz"));
assert!(agents.is_empty());
}
#[test]
fn scan_ignores_manifest_and_non_md() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("manifest.json"), "{}").unwrap();
fs::write(tmp.path().join("notes.txt"), "hello").unwrap();
write_agent(
tmp.path(),
"writer",
"---\nname: writer\nrole: writer\n---\n\n# Writer\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "writer");
}
#[test]
fn scan_handles_no_frontmatter() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"plain",
"# Plain agent\n\nNo frontmatter here.\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "plain");
assert_eq!(agents[0].role, "plain");
assert_eq!(agents[0].extends_chain, vec!["plain".to_string()]);
}
#[test]
fn generate_authority_nonempty() {
let agents = vec![AgentSummary {
name: "engineer".to_string(),
role: "engineer".to_string(),
description: Some("Implements features.".to_string()),
model: Some("sonnet".to_string()),
extends_chain: vec![
"base-agent".to_string(),
"base-engineer".to_string(),
"engineer".to_string(),
],
}];
let md = generate_authority(&agents);
assert!(md.contains("## Delegation Authority"));
assert!(md.contains("### engineer"));
assert!(md.contains("Implements features."));
assert!(md.contains("base-agent → base-engineer → engineer"));
assert!(md.contains("**Model:** sonnet"));
}
#[test]
fn generate_authority_empty() {
let md = generate_authority(&[]);
assert!(md.contains("## Delegation Authority"));
assert!(md.to_lowercase().contains("no delegatable agents"));
}
#[test]
fn scan_preserves_url_in_description() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"docs-agent",
"---\nname: docs-agent\nrole: docs\ndescription: See https://docs.example.com/guide\n---\n\n# Docs\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(
agents[0].description.as_deref(),
Some("See https://docs.example.com/guide"),
"URL in description must not be truncated"
);
}
#[test]
fn scan_preserves_bedrock_model_id() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"ml-agent",
"---\nname: ml-agent\nrole: ml\nmodel: bedrock/us.anthropic.claude-sonnet-4-6\n---\n\n# ML\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(
agents[0].model.as_deref(),
Some("bedrock/us.anthropic.claude-sonnet-4-6"),
"bedrock model id must be preserved verbatim"
);
}
#[test]
fn scan_preserves_timestamp_in_description() {
let tmp = TempDir::new().unwrap();
write_agent(
tmp.path(),
"timed-agent",
"---\nname: timed-agent\nrole: timer\ndescription: Deployed at 2026-06-05T14:31:34\n---\n\n# Timed\n",
);
let agents = scan_agents(tmp.path());
assert_eq!(agents.len(), 1);
assert_eq!(
agents[0].description.as_deref(),
Some("Deployed at 2026-06-05T14:31:34"),
"timestamp in description must not be truncated"
);
}
}