use super::loader::load_config_file;
use super::paths::config_dir;
use super::types::{AgentDef, AgentsSection};
use super::wizard::RoleModels;
use std::path::Path;
pub fn discover_agents(working_dir: &str) -> Vec<AgentDef> {
let mut user_agents: Vec<AgentDef> = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Ok(config_file) = load_config_file() {
for agent_def in config_file.agents.list {
if seen.insert(agent_def.name.clone()) {
user_agents.push(agent_def);
}
}
for (role, val) in [
("arbor", &config_file.agents.arbor),
("architect", &config_file.agents.architect),
("code", &config_file.agents.code),
("ask", &config_file.agents.ask),
] {
if seen.contains(role) {
continue;
}
let Some(prov_model) = val else { continue };
let (provider, model) = if let Some(slash) = prov_model.find('/') {
(
Some(prov_model[..slash].to_string()),
prov_model[slash + 1..].to_string(),
)
} else {
(None, prov_model.clone())
};
seen.insert(role.to_string());
user_agents.push(AgentDef {
name: role.to_string(),
model,
provider,
providers: vec![prov_model.clone()],
..Default::default()
});
}
}
load_agents_from_md_file(
&Path::new(working_dir).join("AGENTS.md"),
&mut user_agents,
&mut seen,
);
load_agents_from_dir(
&Path::new(working_dir).join(".claude").join("agents"),
&mut user_agents,
&mut seen,
);
load_agents_from_md_file(&config_dir().join("AGENTS.md"), &mut user_agents, &mut seen);
load_agents_from_dir(&config_dir().join("agents"), &mut user_agents, &mut seen);
let mut result: Vec<AgentDef> = Vec::new();
for builtin in AgentsSection::default().list {
if let Some(pos) = user_agents.iter().position(|a| a.name == builtin.name) {
let mut agent = user_agents.remove(pos);
if agent.tier.is_none() {
agent.tier = builtin.tier;
}
result.push(agent);
} else {
result.push(builtin);
}
}
result.extend(user_agents);
tracing::info!(count = result.len(), "Agent definitions loaded");
result
}
fn load_agents_from_md_file(
path: &Path,
agents: &mut Vec<AgentDef>,
seen: &mut std::collections::HashSet<String>,
) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
for line in content.lines() {
let rest = line
.trim()
.strip_prefix("### ")
.or_else(|| line.trim().strip_prefix("## "));
let Some(rest) = rest else { continue };
let Some(idx) = rest.find("model:") else {
continue;
};
let name = rest[..idx].trim().to_string();
let model = rest[idx + 6..].trim().to_string();
if !name.is_empty() && !model.is_empty() && seen.insert(name.clone()) {
agents.push(AgentDef {
name,
model,
provider: None,
system_prompt: String::new(),
..AgentDef::default()
});
}
}
}
fn load_agents_from_dir(
dir: &Path,
agents: &mut Vec<AgentDef>,
seen: &mut std::collections::HashSet<String>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(agent) = parse_agent_md(&path)
&& seen.insert(agent.name.clone())
{
agents.push(agent);
}
}
}
pub(crate) fn parse_agent_md(path: &Path) -> Option<AgentDef> {
let content = std::fs::read_to_string(path).ok()?;
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let end = after_first.find("---")?;
let frontmatter = &after_first[..end];
let name = extract_yaml_value(frontmatter, "name")?;
let description = extract_yaml_value(frontmatter, "description");
let tier = extract_yaml_value(frontmatter, "tier").and_then(|v| match v.as_str() {
"heavy" | "medium" | "light" => Some(v),
other => {
tracing::warn!(agent = %name, tier = %other, "Unknown tier value, ignoring");
None
}
});
let raw_model = extract_yaml_value(frontmatter, "model").unwrap_or_default();
let explicit_provider = extract_yaml_value(frontmatter, "provider");
let (model, provider) = if explicit_provider.is_none() {
if let Some(slash) = raw_model.find('/') {
let p = raw_model[..slash].to_string();
let m = raw_model[slash + 1..].to_string();
(m, Some(p))
} else {
(raw_model, None)
}
} else {
(raw_model, explicit_provider)
};
let providers: Vec<String> = extract_yaml_value(frontmatter, "providers")
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let (model, provider) = if model.is_empty() && provider.is_none() {
if let Some(first) = providers.first() {
if let Some(slash) = first.find('/') {
(
first[slash + 1..].to_string(),
Some(first[..slash].to_string()),
)
} else {
(model, provider)
}
} else {
(model, provider)
}
} else {
(model, provider)
};
let tags: Vec<String> = {
let t = extract_yaml_list(frontmatter, "tags");
if t.is_empty() {
extract_yaml_list(frontmatter, "categories")
} else {
t
}
};
let temperature: Option<f32> =
extract_yaml_value(frontmatter, "temperature").and_then(|v| v.parse().ok());
let thinking_budget_tokens: Option<u32> =
extract_yaml_value(frontmatter, "thinking_budget_tokens").and_then(|v| v.parse().ok());
let reasoning_effort = extract_yaml_value(frontmatter, "reasoning_effort");
let max_iterations: Option<u32> =
extract_yaml_value(frontmatter, "max_iterations").and_then(|v| v.parse().ok());
let iteration_delay_ms: Option<u64> =
extract_yaml_value(frontmatter, "iteration_delay_ms").and_then(|v| v.parse().ok());
let max_output_tokens: Option<u32> =
extract_yaml_value(frontmatter, "max_output_tokens").and_then(|v| v.parse().ok());
let supports_tools: Option<bool> =
extract_yaml_value(frontmatter, "supports_tools").and_then(|v| v.parse().ok());
let supports_reasoning: Option<bool> =
extract_yaml_value(frontmatter, "supports_reasoning").and_then(|v| v.parse().ok());
let context_window: Option<usize> =
extract_yaml_value(frontmatter, "context_window").and_then(|v| v.parse().ok());
let soul: Option<bool> = extract_yaml_value(frontmatter, "soul").and_then(|v| v.parse().ok());
let body = after_first[end + 3..].trim().to_string();
Some(AgentDef {
name,
description,
tier,
model,
provider,
providers,
tags,
system_prompt: body,
temperature,
thinking_budget_tokens,
reasoning_effort,
max_iterations,
iteration_delay_ms,
max_output_tokens,
supports_tools,
supports_reasoning,
context_window,
soul,
})
}
fn extract_yaml_value(yaml: &str, field: &str) -> Option<String> {
for line in yaml.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix(field) {
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix(':') {
let value = value.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
None
}
pub fn extract_yaml_list(yaml: &str, field: &str) -> Vec<String> {
let lines: Vec<&str> = yaml.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix(field) else {
continue;
};
let rest = rest.trim_start();
let Some(after_colon) = rest.strip_prefix(':') else {
continue;
};
let value = after_colon.trim();
if value.is_empty() {
return lines[i + 1..]
.iter()
.take_while(|l| {
let t = l.trim();
t.starts_with("- ") || t.is_empty()
})
.filter_map(|l| {
l.trim()
.strip_prefix("- ")
.map(|v| v.trim().trim_matches('"').trim_matches('\'').to_lowercase())
})
.filter(|s| !s.is_empty())
.collect();
} else if value.starts_with('[') && value.ends_with(']') {
let inner = &value[1..value.len() - 1];
return inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_lowercase())
.filter(|s| !s.is_empty())
.collect();
} else {
return value
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_lowercase())
.filter(|s| !s.is_empty())
.collect();
}
}
vec![]
}
pub fn apply_roles_to_agents(agents_dir: &Path, roles: &RoleModels, provider_name: &str) {
let Ok(entries) = std::fs::read_dir(agents_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let agent = match parse_agent_md(&path) {
Some(a) => a,
None => continue,
};
if matches!(agent.name.as_str(), "architect" | "code" | "ask") {
continue;
}
let agent_prov = agent.provider.as_deref().unwrap_or("");
if !agent_prov.is_empty() {
continue;
}
let model = &roles.code;
update_agent_md_frontmatter(&path, model, provider_name);
}
}
fn update_agent_md_frontmatter(path: &Path, model: &str, provider: &str) {
update_agent_md_frontmatter_full(path, model, provider, None);
}
pub(crate) fn update_agent_md_frontmatter_full(
path: &Path,
model: &str,
provider: &str,
providers_chain: Option<&str>,
) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return;
}
let after_first = &trimmed[3..];
let end = match after_first.find("---") {
Some(e) => e,
None => return,
};
let frontmatter = &after_first[..end];
let body_text = after_first[end + 3..].trim_start_matches(['\n', '\r']);
let chain: String = match providers_chain {
Some(c) if !c.is_empty() => c.to_string(),
_ => format!("{provider}/{model}"),
};
let mut new_lines: Vec<String> = Vec::new();
let mut has_providers = false;
for line in frontmatter.trim().lines() {
let trimmed_line = line.trim();
if trimmed_line.starts_with("model:")
|| trimmed_line.starts_with("model :")
|| trimmed_line.starts_with("provider:")
|| trimmed_line.starts_with("provider :")
{
} else if trimmed_line.starts_with("providers:") || trimmed_line.starts_with("providers :")
{
new_lines.push(format!("providers: {chain}"));
has_providers = true;
} else {
new_lines.push(line.to_string());
}
}
if !has_providers {
let pos = new_lines
.iter()
.position(|l| l.trim().starts_with("name:"))
.map(|i| i + 1)
.unwrap_or(0);
new_lines.insert(pos, format!("providers: {chain}"));
}
let result = if body_text.is_empty() {
format!("---\n{}\n---\n", new_lines.join("\n"))
} else {
format!("---\n{}\n---\n\n{}", new_lines.join("\n"), body_text)
};
let _ = std::fs::write(path, result);
}