// vapora-agents: Agent configuration module // Load and parse agent definitions from TOML use std::path::Path; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Error)] pub enum ConfigError { #[error("Failed to read config file: {0}")] ReadError(#[from] std::io::Error), #[error("Failed to parse config: {0}")] ParseJson(#[from] serde_json::Error), #[error("Invalid configuration: {0}")] ValidationError(String), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { pub registry: RegistryConfig, pub agents: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryConfig { #[serde(default = "default_max_agents")] pub max_agents_per_role: u32, #[serde(default = "default_health_check_interval")] pub health_check_interval: u64, #[serde(default = "default_agent_timeout")] pub agent_timeout: u64, } fn default_max_agents() -> u32 { 5 } fn default_health_check_interval() -> u64 { 30 } fn default_agent_timeout() -> u64 { 300 } /// Re-exported from `vapora-shared` so callers using /// `vapora_agents::config::AgentDefinition` continue to compile without /// changes. pub use vapora_shared::AgentDefinition; impl AgentConfig { /// Load configuration from a TOML or NCL file. When the path has a `.ncl` /// extension, `nickel export --format json` is invoked and the resulting /// JSON is parsed. Otherwise the file is read and parsed as TOML. pub fn load>(path: P) -> Result { let path = path.as_ref(); let (raw, is_json) = if path.extension().and_then(|e| e.to_str()) == Some("ncl") { let out = std::process::Command::new("nickel") .args(["export", "--format", "json"]) .arg(path) .output() .map_err(|e| { ConfigError::ReadError(std::io::Error::other(format!( "Failed to invoke nickel for {:?}: {}", path, e ))) })?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); return Err(ConfigError::ReadError(std::io::Error::other(format!( "nickel export failed for {:?}: {}", path, stderr )))); } let json = String::from_utf8(out.stdout).map_err(|e| { ConfigError::ReadError(std::io::Error::other(format!( "nickel output is not valid UTF-8: {}", e ))) })?; (json, true) } else { let content = std::fs::read_to_string(path)?; (content, false) }; let interpolated = interpolate_env_vars(&raw); let config: Self = if is_json { serde_json::from_str(&interpolated)? } else { toml::from_str(&interpolated).map_err(|e| { ConfigError::ReadError(std::io::Error::other(format!( "Failed to parse TOML: {}", e ))) })? }; config.validate()?; Ok(config) } /// Load configuration from environment or default file pub fn from_env() -> Result { let config_path = std::env::var("VAPORA_AGENT_CONFIG") .unwrap_or_else(|_| "config/config.ncl".to_string()); if Path::new(&config_path).exists() { Self::load(&config_path) } else { Ok(Self::default()) } } /// Validate configuration fn validate(&self) -> Result<(), ConfigError> { // Check that all agent roles are unique let mut roles = std::collections::HashSet::new(); for agent in &self.agents { if !roles.insert(&agent.role) { return Err(ConfigError::ValidationError(format!( "Duplicate agent role: {}", agent.role ))); } } // Check that we have at least one agent if self.agents.is_empty() { return Err(ConfigError::ValidationError( "No agents defined in configuration".to_string(), )); } Ok(()) } /// Get agent definition by role pub fn get_by_role(&self, role: &str) -> Option<&AgentDefinition> { self.agents.iter().find(|a| a.role == role) } /// List all agent roles pub fn list_roles(&self) -> Vec { self.agents.iter().map(|a| a.role.clone()).collect() } } impl Default for AgentConfig { fn default() -> Self { Self { registry: RegistryConfig { max_agents_per_role: default_max_agents(), health_check_interval: default_health_check_interval(), agent_timeout: default_agent_timeout(), }, agents: vec![AgentDefinition { role: "developer".to_string(), description: "Code developer".to_string(), llm_provider: "claude".to_string(), llm_model: "claude-sonnet-4".to_string(), parallelizable: true, priority: 80, capabilities: vec!["coding".to_string()], system_prompt: None, }], } } } /// Expand every `${VAR}` / `${VAR:-default}` reference in `content`. /// Unresolved vars without a default are replaced with an empty string. fn interpolate_env_vars(content: &str) -> String { let mut result = String::with_capacity(content.len()); let mut remaining = content; while let Some(start) = remaining.find("${") { result.push_str(&remaining[..start]); let after_open = &remaining[start + 2..]; if let Some(close) = after_open.find('}') { let var_expr = &after_open[..close]; let value = if let Some(sep) = var_expr.find(":-") { let var_name = &var_expr[..sep]; let default_val = &var_expr[sep + 2..]; std::env::var(var_name).unwrap_or_else(|_| default_val.to_string()) } else { std::env::var(var_expr).unwrap_or_default() }; result.push_str(&value); remaining = &after_open[close + 1..]; } else { result.push_str("${"); remaining = after_open; } } result.push_str(remaining); result } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_values() { let config = AgentConfig { registry: RegistryConfig { max_agents_per_role: 5, health_check_interval: 30, agent_timeout: 300, }, agents: vec![AgentDefinition { role: "developer".to_string(), description: "Code developer".to_string(), llm_provider: "claude".to_string(), llm_model: "claude-sonnet-4".to_string(), parallelizable: true, priority: 80, capabilities: vec!["coding".to_string()], system_prompt: None, }], }; assert!(config.validate().is_ok()); } #[test] fn test_duplicate_roles() { let config = AgentConfig { registry: RegistryConfig { max_agents_per_role: 5, health_check_interval: 30, agent_timeout: 300, }, agents: vec![ AgentDefinition { role: "developer".to_string(), description: "Code developer 1".to_string(), llm_provider: "claude".to_string(), llm_model: "claude-sonnet-4".to_string(), parallelizable: true, priority: 80, capabilities: vec![], system_prompt: None, }, AgentDefinition { role: "developer".to_string(), description: "Code developer 2".to_string(), llm_provider: "claude".to_string(), llm_model: "claude-sonnet-4".to_string(), parallelizable: true, priority: 80, capabilities: vec![], system_prompt: None, }, ], }; assert!(config.validate().is_err()); } #[test] fn test_get_by_role() { let config = AgentConfig { registry: RegistryConfig { max_agents_per_role: 5, health_check_interval: 30, agent_timeout: 300, }, agents: vec![AgentDefinition { role: "architect".to_string(), description: "System architect".to_string(), llm_provider: "claude".to_string(), llm_model: "claude-opus-4".to_string(), parallelizable: false, priority: 100, capabilities: vec!["architecture".to_string()], system_prompt: None, }], }; let agent = config.get_by_role("architect"); assert!(agent.is_some()); assert_eq!(agent.unwrap().description, "System architect"); assert!(config.get_by_role("nonexistent").is_none()); } }