use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{LlmError, Result};
use crate::retry::RetryConfig;
use crate::types::Tool;
fn default_chain_limit() -> usize {
10
}
fn default_parallel_tools() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default = "default_chain_limit")]
pub chain_limit: usize,
#[serde(default)]
pub options: HashMap<String, serde_json::Value>,
#[serde(default)]
pub budget: Option<BudgetConfig>,
#[serde(default)]
pub retry: Option<RetryConfig>,
#[serde(default = "default_parallel_tools")]
pub parallel_tools: bool,
#[serde(default)]
pub max_parallel_tools: Option<usize>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
model: None,
system_prompt: None,
tools: Vec::new(),
chain_limit: default_chain_limit(),
options: HashMap::new(),
budget: None,
retry: None,
parallel_tools: default_parallel_tools(),
max_parallel_tools: None,
}
}
}
impl AgentConfig {
pub fn load(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
LlmError::Config(format!("agent config not found: {}", path.display()))
} else {
LlmError::Io(e)
}
})?;
toml::from_str(&contents).map_err(|e| LlmError::Config(e.to_string()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetConfig {
#[serde(default)]
pub max_tokens: Option<u64>,
}
pub fn resolve_agent_model<'a>(config: &'a AgentConfig, client_default: &'a str) -> &'a str {
config.model.as_deref().unwrap_or(client_default)
}
pub fn resolve_agent_system<'a>(
arg: Option<&'a str>,
config: &'a AgentConfig,
) -> Option<&'a str> {
arg.or(config.system_prompt.as_deref())
}
pub fn resolve_agent_retry(
cli_arg: Option<u32>,
config: &AgentConfig,
client_default: &RetryConfig,
) -> RetryConfig {
if let Some(n) = cli_arg {
let mut cfg = client_default.clone();
cfg.max_retries = n;
return cfg;
}
if let Some(agent_retry) = &config.retry {
return agent_retry.clone();
}
client_default.clone()
}
pub fn resolve_agent_tools(
config: &AgentConfig,
registry_tools: &[Tool],
) -> Result<Vec<Tool>> {
let mut out = Vec::with_capacity(config.tools.len());
for name in &config.tools {
match registry_tools.iter().find(|t| t.name == *name) {
Some(t) => out.push(t.clone()),
None => {
return Err(LlmError::Config(format!(
"unknown tool in agent config: {name}"
)));
}
}
}
Ok(out)
}
pub fn resolve_agent_budget(config: &AgentConfig) -> Option<u64> {
config.budget.as_ref().and_then(|b| b.max_tokens)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentSource {
Global,
Local,
}
impl std::fmt::Display for AgentSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentSource::Global => write!(f, "global"),
AgentSource::Local => write!(f, "local"),
}
}
}
#[derive(Debug, Clone)]
pub struct AgentInfo {
pub name: String,
pub path: PathBuf,
pub source: AgentSource,
}
pub fn discover_agents(
global_dir: &Path,
local_dir: Option<&Path>,
) -> Result<Vec<AgentInfo>> {
let mut agents: HashMap<String, AgentInfo> = HashMap::new();
scan_agents_dir(global_dir, AgentSource::Global, &mut agents)?;
if let Some(local) = local_dir {
scan_agents_dir(local, AgentSource::Local, &mut agents)?;
}
let mut result: Vec<AgentInfo> = agents.into_values().collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
Ok(result)
}
pub fn resolve_agent(
name: &str,
global_dir: &Path,
local_dir: Option<&Path>,
) -> Result<(AgentConfig, PathBuf)> {
if let Some(local) = local_dir {
let path = local.join(format!("{name}.toml"));
if path.exists() {
let config = AgentConfig::load(&path)?;
return Ok((config, path));
}
}
let path = global_dir.join(format!("{name}.toml"));
if path.exists() {
let config = AgentConfig::load(&path)?;
return Ok((config, path));
}
Err(LlmError::Config(format!("agent not found: {name}")))
}
fn scan_agents_dir(
dir: &Path,
source: AgentSource,
agents: &mut HashMap<String, AgentInfo>,
) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(LlmError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
agents.insert(
stem.to_string(),
AgentInfo {
name: stem.to_string(),
path,
source: source.clone(),
},
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_config_default() {
let config = AgentConfig::default();
assert!(config.model.is_none());
assert!(config.system_prompt.is_none());
assert!(config.tools.is_empty());
assert_eq!(config.chain_limit, 10);
assert!(config.options.is_empty());
assert!(config.budget.is_none());
assert!(config.retry.is_none());
assert!(config.parallel_tools);
assert!(config.max_parallel_tools.is_none());
}
#[test]
fn agent_config_load_full_toml() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("reviewer.toml");
std::fs::write(
&path,
r#"
model = "claude-sonnet-4-20250514"
system_prompt = "You are a code reviewer."
tools = ["ripgrep", "read_file", "llm_time"]
chain_limit = 20
[options]
temperature = 0
[budget]
max_tokens = 50000
"#,
)
.unwrap();
let config = AgentConfig::load(&path).unwrap();
assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(config.system_prompt.as_deref(), Some("You are a code reviewer."));
assert_eq!(config.tools, vec!["ripgrep", "read_file", "llm_time"]);
assert_eq!(config.chain_limit, 20);
assert_eq!(config.options["temperature"], serde_json::json!(0));
let budget = config.budget.unwrap();
assert_eq!(budget.max_tokens, Some(50000));
}
#[test]
fn agent_config_load_minimal_toml() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("minimal.toml");
std::fs::write(&path, "model = \"gpt-4o-mini\"\n").unwrap();
let config = AgentConfig::load(&path).unwrap();
assert_eq!(config.model.as_deref(), Some("gpt-4o-mini"));
assert!(config.system_prompt.is_none());
assert!(config.tools.is_empty());
assert_eq!(config.chain_limit, 10); assert!(config.options.is_empty());
}
#[test]
fn agent_config_load_with_options() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("opts.toml");
std::fs::write(
&path,
r#"
[options]
temperature = 0.7
max_tokens = 200
"#,
)
.unwrap();
let config = AgentConfig::load(&path).unwrap();
assert_eq!(config.options["temperature"], serde_json::json!(0.7));
assert_eq!(config.options["max_tokens"], serde_json::json!(200));
}
#[test]
fn agent_config_load_missing_file() {
let result = AgentConfig::load(Path::new("/nonexistent/agent.toml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, LlmError::Config(_)));
assert!(err.to_string().contains("not found"));
}
#[test]
fn agent_config_load_invalid_toml() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("bad.toml");
std::fs::write(&path, "not valid {{{{ toml").unwrap();
let result = AgentConfig::load(&path);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), LlmError::Config(_)));
}
#[test]
fn agent_config_chain_limit_default() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("empty.toml");
std::fs::write(&path, "").unwrap();
let config = AgentConfig::load(&path).unwrap();
assert_eq!(config.chain_limit, 10);
}
#[test]
fn discover_agents_empty_dirs() {
let global = tempfile::tempdir().unwrap();
let local = tempfile::tempdir().unwrap();
let agents = discover_agents(global.path(), Some(local.path())).unwrap();
assert!(agents.is_empty());
}
#[test]
fn discover_agents_global_only() {
let global = tempfile::tempdir().unwrap();
std::fs::write(
global.path().join("reviewer.toml"),
"model = \"gpt-4o\"\n",
)
.unwrap();
let agents = discover_agents(global.path(), None).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "reviewer");
assert_eq!(agents[0].source, AgentSource::Global);
}
#[test]
fn discover_agents_local_only() {
let global = tempfile::tempdir().unwrap();
let local = tempfile::tempdir().unwrap();
std::fs::write(
local.path().join("helper.toml"),
"model = \"gpt-4o-mini\"\n",
)
.unwrap();
let agents = discover_agents(global.path(), Some(local.path())).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "helper");
assert_eq!(agents[0].source, AgentSource::Local);
}
#[test]
fn discover_agents_local_shadows_global() {
let global = tempfile::tempdir().unwrap();
let local = tempfile::tempdir().unwrap();
std::fs::write(
global.path().join("reviewer.toml"),
"model = \"gpt-4o\"\n",
)
.unwrap();
std::fs::write(
local.path().join("reviewer.toml"),
"model = \"gpt-4o-mini\"\n",
)
.unwrap();
let agents = discover_agents(global.path(), Some(local.path())).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "reviewer");
assert_eq!(agents[0].source, AgentSource::Local);
}
#[test]
fn discover_agents_sorted() {
let global = tempfile::tempdir().unwrap();
std::fs::write(global.path().join("zebra.toml"), "").unwrap();
std::fs::write(global.path().join("alpha.toml"), "").unwrap();
std::fs::write(global.path().join("mid.toml"), "").unwrap();
let agents = discover_agents(global.path(), None).unwrap();
let names: Vec<&str> = agents.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mid", "zebra"]);
}
#[test]
fn discover_agents_non_toml_ignored() {
let global = tempfile::tempdir().unwrap();
std::fs::write(global.path().join("agent.toml"), "").unwrap();
std::fs::write(global.path().join("readme.md"), "# agents").unwrap();
std::fs::write(global.path().join("notes.txt"), "some notes").unwrap();
let agents = discover_agents(global.path(), None).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].name, "agent");
}
#[test]
fn discover_agents_nonexistent_dirs() {
let agents = discover_agents(
Path::new("/nonexistent/global"),
Some(Path::new("/nonexistent/local")),
)
.unwrap();
assert!(agents.is_empty());
}
#[test]
fn resolve_agent_found() {
let global = tempfile::tempdir().unwrap();
std::fs::write(
global.path().join("reviewer.toml"),
"model = \"gpt-4o\"\nsystem_prompt = \"Review code.\"\n",
)
.unwrap();
let (config, path) = resolve_agent("reviewer", global.path(), None).unwrap();
assert_eq!(config.model.as_deref(), Some("gpt-4o"));
assert_eq!(config.system_prompt.as_deref(), Some("Review code."));
assert_eq!(path, global.path().join("reviewer.toml"));
}
#[test]
fn resolve_agent_not_found() {
let global = tempfile::tempdir().unwrap();
let result = resolve_agent("nonexistent", global.path(), None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, LlmError::Config(_)));
assert!(err.to_string().contains("agent not found"));
}
#[test]
fn agent_config_parses_retry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("retry.toml");
std::fs::write(
&path,
r#"
[retry]
max_retries = 5
base_delay_ms = 500
"#,
)
.unwrap();
let config = AgentConfig::load(&path).unwrap();
let retry = config.retry.unwrap();
assert_eq!(retry.max_retries, 5);
assert_eq!(retry.base_delay_ms, 500);
assert_eq!(retry.max_delay_ms, 30_000);
assert!(retry.jitter);
}
#[test]
fn agent_config_retry_defaults() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("retry_defaults.toml");
std::fs::write(&path, "[retry]\n").unwrap();
let config = AgentConfig::load(&path).unwrap();
let retry = config.retry.unwrap();
assert_eq!(retry.max_retries, 3);
assert_eq!(retry.base_delay_ms, 1000);
assert_eq!(retry.max_delay_ms, 30_000);
assert!(retry.jitter);
}
#[test]
fn agent_config_parses_parallel_tools_fields() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("parallel.toml");
std::fs::write(
&path,
r#"
parallel_tools = false
max_parallel_tools = 3
"#,
)
.unwrap();
let config = AgentConfig::load(&path).unwrap();
assert!(!config.parallel_tools);
assert_eq!(config.max_parallel_tools, Some(3));
}
#[test]
fn agent_config_parallel_tools_defaults() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("defaults.toml");
std::fs::write(&path, "model = \"gpt-4o-mini\"\n").unwrap();
let config = AgentConfig::load(&path).unwrap();
assert!(config.parallel_tools, "parallel_tools should default to true");
assert_eq!(config.max_parallel_tools, None);
}
#[test]
fn agent_config_no_retry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("no_retry.toml");
std::fs::write(&path, "model = \"gpt-4o-mini\"\n").unwrap();
let config = AgentConfig::load(&path).unwrap();
assert!(config.retry.is_none());
}
fn tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
description: format!("{name} tool"),
input_schema: serde_json::json!({"type": "object"}),
}
}
#[test]
fn resolve_model_uses_config_when_set() {
let mut cfg = AgentConfig::default();
cfg.model = Some("gpt-4o".into());
assert_eq!(resolve_agent_model(&cfg, "gpt-4o-mini"), "gpt-4o");
}
#[test]
fn resolve_model_falls_back_to_client_default() {
let cfg = AgentConfig::default();
assert_eq!(resolve_agent_model(&cfg, "gpt-4o-mini"), "gpt-4o-mini");
}
#[test]
fn resolve_system_prefers_arg_over_config() {
let mut cfg = AgentConfig::default();
cfg.system_prompt = Some("from config".into());
assert_eq!(
resolve_agent_system(Some("from arg"), &cfg),
Some("from arg")
);
}
#[test]
fn resolve_system_uses_config_when_no_arg() {
let mut cfg = AgentConfig::default();
cfg.system_prompt = Some("from config".into());
assert_eq!(resolve_agent_system(None, &cfg), Some("from config"));
}
#[test]
fn resolve_system_none_when_neither() {
let cfg = AgentConfig::default();
assert_eq!(resolve_agent_system(None, &cfg), None);
}
#[test]
fn resolve_retry_cli_arg_wins() {
let cfg = AgentConfig::default();
let client = RetryConfig::default();
let out = resolve_agent_retry(Some(7), &cfg, &client);
assert_eq!(out.max_retries, 7);
}
#[test]
fn resolve_retry_agent_config_wins_over_default() {
let mut cfg = AgentConfig::default();
cfg.retry = Some(RetryConfig {
max_retries: 5,
base_delay_ms: 123,
max_delay_ms: 456,
jitter: false,
});
let client = RetryConfig::default();
let out = resolve_agent_retry(None, &cfg, &client);
assert_eq!(out.max_retries, 5);
assert_eq!(out.base_delay_ms, 123);
}
#[test]
fn resolve_retry_falls_back_to_client() {
let cfg = AgentConfig::default();
let client = RetryConfig {
max_retries: 2,
base_delay_ms: 1000,
max_delay_ms: 30_000,
jitter: true,
};
let out = resolve_agent_retry(None, &cfg, &client);
assert_eq!(out.max_retries, 2);
}
#[test]
fn resolve_tools_filters_to_agent_whitelist() {
let mut cfg = AgentConfig::default();
cfg.tools = vec!["read_file".into(), "llm_time".into()];
let registry = vec![tool("read_file"), tool("ripgrep"), tool("llm_time")];
let out = resolve_agent_tools(&cfg, ®istry).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "read_file");
assert_eq!(out[1].name, "llm_time");
}
#[test]
fn resolve_tools_errors_on_unknown_with_cli_format() {
let mut cfg = AgentConfig::default();
cfg.tools = vec!["missing".into()];
let registry = vec![tool("read_file")];
let err = resolve_agent_tools(&cfg, ®istry).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown tool in agent config: missing"),
"got: {msg}"
);
}
#[test]
fn resolve_tools_empty_config_returns_empty() {
let cfg = AgentConfig::default();
let registry = vec![tool("read_file")];
let out = resolve_agent_tools(&cfg, ®istry).unwrap();
assert!(out.is_empty());
}
#[test]
fn resolve_budget_extracts_max_tokens() {
let mut cfg = AgentConfig::default();
cfg.budget = Some(BudgetConfig {
max_tokens: Some(5000),
});
assert_eq!(resolve_agent_budget(&cfg), Some(5000));
}
#[test]
fn resolve_budget_none_when_unset() {
let cfg = AgentConfig::default();
assert_eq!(resolve_agent_budget(&cfg), None);
}
#[test]
fn resolve_agent_local_wins() {
let global = tempfile::tempdir().unwrap();
let local = tempfile::tempdir().unwrap();
std::fs::write(
global.path().join("reviewer.toml"),
"model = \"gpt-4o\"\n",
)
.unwrap();
std::fs::write(
local.path().join("reviewer.toml"),
"model = \"claude-sonnet-4-20250514\"\n",
)
.unwrap();
let (config, path) = resolve_agent("reviewer", global.path(), Some(local.path())).unwrap();
assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(path, local.path().join("reviewer.toml"));
}
}