use crate::{Result, RoutexError};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub runtime: RuntimeConfig,
pub task: TaskConfig,
#[serde(default)]
pub agents: Vec<AgentConfig>,
#[serde(default)]
pub tools: Vec<ToolConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RuntimeConfig {
#[serde(default = "default_runtime_name")]
pub name: String,
pub llm_provider: String,
pub model: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Planner,
Writer,
Critic,
Executor,
Researcher,
}
impl Role {
pub fn system_prompt(&self) -> String {
match self {
Role::Planner => "You are a planning agent. Your only job is to read the task \
and break it down into a clear, numbered list of steps for other \
agents to follow. Do not do the work yourself — only plan it. \
Be specific and actionable."
.to_string(),
Role::Writer => "You are a writing agent. You receive a plan and execute it \
by researching, thinking, and producing well-structured written output. \
Use your tools when you need information from the web or files. \
Be thorough and cite your sources."
.to_owned(),
Role::Critic => "You are a critic agent. You receive a piece of work and review it \
for quality, accuracy, completeness, and clarity. \
Be constructive. Point out what is good, what is missing, \
and what could be improved. Give a score out of 10."
.to_string(),
Role::Executor => "You are an executor agent. You carry out specific actions \
using the tools available to you. Follow instructions precisely. \
Report back exactly what happened — success or failure."
.to_string(),
Role::Researcher => "You are a research agent. Your job is to find, read, and \
summarise information relevant to the task. \
Do not produce long reports — produce concise, factual summaries \
that other agents can use."
.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub id: String,
pub role: Role,
pub goal: String,
#[serde(default)]
pub backstory: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub depends: Vec<String>,
#[serde(default = "default_restart_policy")]
pub restart: String,
#[serde(default)]
pub llm: Option<AgentLlmConfig>,
#[serde(default = "default_max_tool_calls")]
pub max_tool_calls: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentLlmConfig {
pub provider: String,
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskConfig {
pub input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolConfig {
pub name: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_dir: Option<String>,
#[serde(default)]
pub max_results: Option<u32>,
#[serde(default)]
pub extra: HashMap<String, String>,
}
fn default_runtime_name() -> String {
"routex".to_owned()
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_max_tokens() -> u32 {
4096
}
fn default_restart_policy() -> String {
"one_for_one".to_string()
}
fn default_max_tool_calls() -> u32 {
20
}
impl Config {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
RoutexError::Config(format!("Could not read {}: {}", path.as_ref().display(), e))
})?;
let mut config: Config = serde_yaml::from_str(&content)?;
config.resolve_env();
config.validate()?;
Ok(config)
}
fn resolve_env(&mut self) {
self.runtime.api_key = resolve_env_value(&self.runtime.api_key);
for agent in &mut self.agents {
if let Some(llm) = &mut agent.llm {
if let Some(key) = &llm.api_key {
llm.api_key = Some(resolve_env_value(key))
}
llm.provider = resolve_env_value(&llm.provider);
llm.model = resolve_env_value(&llm.model);
if let Some(base_url) = &llm.base_url {
llm.base_url = Some(resolve_env_value(base_url));
}
}
}
for tool in &mut self.tools {
tool.name = resolve_env_value(&tool.name);
if let Some(api_key) = &tool.api_key {
tool.api_key = Some(resolve_env_value(&api_key));
}
if let Some(base_dir) = &tool.base_dir {
tool.base_dir = Some(resolve_env_value(&base_dir));
}
for extra in tool.extra.values_mut() {
*extra = resolve_env_value(extra);
}
}
}
fn validate(&self) -> Result<()> {
if self.agents.is_empty() {
return Err(RoutexError::Config(
"agents.yaml must declare at least one agent".to_string(),
));
}
let agents = &self.agents;
let mut agent_ids = HashSet::new();
for agent in agents {
if agent.id.is_empty() {
return Err(RoutexError::Config(
"every agent must have a non-empty id".to_string(),
));
}
if !agent_ids.insert(&agent.id) {
return Err(RoutexError::Config(format!(
"duplicate agent id: '{}'",
agent.id
)));
}
}
for agent in agents {
for dep in &agent.depends {
if !agent_ids.contains(dep) {
return Err(RoutexError::UnknownDependency {
id: agent.id.clone(),
dep: dep.clone(),
});
}
}
}
Ok(())
}
}
fn resolve_env_value(value: &str) -> String {
if let Some(var_name) = value.strip_prefix("env:") {
std::env::var(var_name).unwrap_or_default()
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_temp_yaml(contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", contents).unwrap();
file
}
#[test]
fn test_parse_minimal_config() {
let yaml = r#"
runtime:
name: "test-crew"
llm_provider: "anthropic"
model: "claude-haiku-4-5-20251001"
api_key: "test-key"
task:
input: "test task"
agents:
- id: "researcher"
role: "researcher"
goal: "research the topic"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.runtime.name, "test-crew");
assert_eq!(config.runtime.api_key, "test-key");
assert_eq!(config.agents.len(), 1);
assert_eq!(config.agents[0].id, "researcher");
}
#[test]
fn test_defaults_applied() {
let yaml = r#"
runtime:
llm_provider: "anthropic"
model: "claude-haiku-4-5-20251001"
api_key: "key"
task:
input: "test"
agents:
- id: "a"
role: "researcher"
goal: "research"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.runtime.name, default_runtime_name());
assert_eq!(config.runtime.log_level, default_log_level());
assert_eq!(config.runtime.max_tokens, default_max_tokens());
assert_eq!(config.agents[0].restart, default_restart_policy());
assert_eq!(config.agents[0].max_tool_calls, default_max_tool_calls());
}
#[test]
fn test_env_resolution() {
unsafe {
std::env::set_var("TEST_ROUTEX_KEY", "resolved-value");
}
let yaml = r#"
runtime:
name: "test"
llm_provider: "anthropic"
model: "claude-haiku-4-5-20251001"
api_key: "env:TEST_ROUTEX_KEY"
task:
input: "test"
agents:
- id: "a"
role: "researcher"
goal: "research"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.runtime.api_key, "env:TEST_ROUTEX_KEY");
unsafe {
std::env::remove_var("TEST_ROUTEX_KEY");
}
}
#[test]
fn test_validation_rejects_duplicate_ids() {
let yaml = r#"
runtime:
name: "test"
llm_provider: "anthropic"
model: "claude-haiku-4-5-20251001"
api_key: "key"
task:
input: "test"
agents:
- id: "researcher"
role: "researcher"
goal: "research"
- id: "researcher"
role: "writer"
goal: "write"
"#;
let config = Config::from_file(write_temp_yaml(yaml));
assert!(config.is_err());
assert!(config.unwrap_err().to_string().contains("duplicate"));
}
#[test]
fn test_reject_unknown_dependency() {
let yaml = r#"
runtime:
name: "test"
llm_provider: "anthropic"
model: "claude-haiku-4-5-20251001"
api_key: "key"
task:
input: "test"
agents:
- id: "writer"
role: "writer"
goal: "write"
depends: ["researcher"]
"#;
let config = Config::from_file(write_temp_yaml(yaml));
assert!(config.is_err());
assert!(config.unwrap_err().to_string().contains("researcher"));
}
}