Vapora/crates/vapora-agents/src/config.rs
Jesús Pérez c5f4caa2ab
feat(agents): stable identity + hot-reload for zero learning loss on config change
Introduce stable_id = role on AgentMetadata so learning profiles and KG
  execution records survive process restarts and hot-reloads. Previously
  every Uuid::new_v4() rotation orphaned accumulated expertise.

  - registry: add stable_id field (serde default, backward-compatible),
    stable_id_or_role() fallback helper, drain_role(), list_roles()
  - coordinator: profile lookup and KG writes use stable_id_or_role()
    instead of the ephemeral UUID; drain_role() drops Sender to close
    mpsc channels after in-flight messages drain; registry_arc() accessor
  - executor: agent_id written to KG now uses stable_id_or_role()
  - server: reload_agents() drain-and-respawn function; SIGHUP handler
    via while sighup.recv().await.is_some(); POST /reload endpoint;
    AppState extended with config_path, router, cap_registry
  - fix: SIGHUP recv() spin-loop guard (is_some())
  - fix: io_other_error clippy lint in vapora-agents, vapora-llm-router,
    vapora-workflow-engine (std::io::Error::other instead of Error::new)
  - docs: ADR-0040, CHANGELOG entry, README hot-reload section
2026-03-02 22:54:28 +00:00

292 lines
9.4 KiB
Rust

// 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<AgentDefinition>,
}
#[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<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
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<Self, ConfigError> {
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<String> {
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());
}
}