use cron::Schedule;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use crate::scheduler::Priority;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CronConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_tick_interval")]
pub tick_interval_secs: u64,
#[serde(default)]
pub jobs: std::collections::HashMap<String, InlineCronJob>,
}
impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: false,
tick_interval_secs: default_tick_interval(),
jobs: std::collections::HashMap::new(),
}
}
}
fn default_tick_interval() -> u64 {
60
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InlineCronJob {
pub schedule: String,
pub goal: String,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(default)]
pub acceptance_criteria: Vec<String>,
#[serde(default = "default_toolchain_inline")]
pub toolchain: String,
#[serde(default)]
pub priority: Priority,
#[serde(default = "default_true_inline")]
pub enabled: bool,
}
fn default_toolchain_inline() -> String {
"default".into()
}
fn default_true_inline() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_max_recall")]
pub max_recall: usize,
#[serde(default = "default_true")]
pub auto_summarize: bool,
#[serde(default = "default_true")]
pub capture_compaction: bool,
#[serde(default)]
pub retention_days: u32,
#[serde(default = "default_true")]
pub cache_enabled: bool,
#[serde(default = "default_cache_ttl")]
pub cache_ttl_secs: u64,
#[serde(default = "default_cache_max_entries")]
pub cache_max_entries: usize,
}
fn default_true() -> bool {
true
}
fn default_max_recall() -> usize {
10
}
fn default_cache_ttl() -> u64 {
3600 }
fn default_cache_max_entries() -> usize {
10000
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
enabled: true,
max_recall: 10,
auto_summarize: true,
capture_compaction: true,
retention_days: 0,
cache_enabled: true,
cache_ttl_secs: 3600,
cache_max_entries: 10000,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChannelsConfig {
#[serde(default = "default_channels_enabled")]
pub enabled: Vec<String>,
#[serde(default)]
pub telegram: TelegramChannelConfig,
}
fn default_channels_enabled() -> Vec<String> {
vec!["web".to_string()]
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
enabled: default_channels_enabled(),
telegram: TelegramChannelConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TelegramChannelConfig {
#[serde(default = "default_telegram_token_env")]
pub bot_token_env: String,
#[serde(default)]
pub allowed_users: Vec<i64>,
}
fn default_telegram_token_env() -> String {
"TELEGRAM_BOT_TOKEN".to_string()
}
impl Default for TelegramChannelConfig {
fn default() -> Self {
Self {
bot_token_env: default_telegram_token_env(),
allowed_users: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(clippy::derivable_impls)]
pub struct EngineConfig {
#[serde(default)]
pub default_model: String,
#[serde(default, skip_serializing)]
pub api_key: Option<String>,
}
#[allow(clippy::derivable_impls)]
impl Default for EngineConfig {
fn default() -> Self {
Self {
default_model: String::new(),
api_key: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DaemonConfig {
#[serde(default = "default_pid_file")]
pub pid_file: String,
#[serde(default = "default_daemon_log_dir")]
pub log_dir: String,
}
fn default_pid_file() -> String {
dirs::home_dir()
.map(|h| format!("{}/.oxios/oxios.pid", h.display()))
.unwrap_or_else(|| "./oxios.pid".into())
}
fn default_daemon_log_dir() -> String {
dirs::home_dir()
.map(|h| format!("{}/.oxios/logs", h.display()))
.unwrap_or_else(|| "./logs".into())
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
pid_file: default_pid_file(),
log_dir: default_daemon_log_dir(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct OxiosConfig {
pub kernel: KernelConfig,
#[serde(default)]
pub engine: EngineConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub orchestrator: OrchestratorConfig,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub persona: PersonaConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub cron: CronConfig,
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub budget: BudgetConfig,
#[serde(default)]
pub exec: ExecConfig,
#[serde(default)]
pub resource_monitor: ResourceMonitorConfig,
#[serde(default)]
pub otel: OtelConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub browser: BrowserConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KernelConfig {
#[serde(default = "default_workspace")]
pub workspace: String,
#[serde(default = "default_event_bus_capacity")]
pub event_bus_capacity: usize,
#[serde(default = "default_max_agents")]
pub max_agents: usize,
}
fn default_workspace() -> String {
dirs_home().unwrap_or_else(|| ".".into())
}
fn dirs_home() -> Option<String> {
dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
}
fn default_event_bus_capacity() -> usize {
256
}
fn default_max_agents() -> usize {
16
}
impl Default for KernelConfig {
fn default() -> Self {
Self {
workspace: default_workspace(),
event_bus_capacity: default_event_bus_capacity(),
max_agents: default_max_agents(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_host")]
pub host: String,
#[serde(default = "default_gateway_port")]
pub port: u16,
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_gateway_port() -> u16 {
4200
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: default_gateway_host(),
port: default_gateway_port(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExecMode {
#[default]
Structured,
Shell,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExecConfig {
#[serde(default)]
pub default_mode: ExecMode,
#[serde(default = "default_false")]
pub allow_shell_mode: bool,
#[serde(default)]
pub allowed_commands: Vec<String>,
#[serde(default = "default_exec_timeout")]
pub default_timeout_secs: u64,
#[serde(default = "default_exec_max_timeout")]
pub max_timeout_secs: u64,
#[serde(default)]
pub required_host_tools: Vec<String>,
#[serde(default)]
pub optional_host_tools: Vec<String>,
}
fn default_false() -> bool {
false
}
fn default_exec_timeout() -> u64 {
120
}
fn default_exec_max_timeout() -> u64 {
600
}
impl ExecConfig {
pub fn is_binary_allowed(&self, name: &str) -> bool {
self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
}
}
impl Default for ExecConfig {
fn default() -> Self {
Self {
default_mode: ExecMode::default(),
allow_shell_mode: default_false(),
allowed_commands: Vec::new(),
default_timeout_secs: default_exec_timeout(),
max_timeout_secs: default_exec_max_timeout(),
required_host_tools: Vec::new(),
optional_host_tools: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchedulerConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
#[serde(default = "default_rate_limit")]
pub rate_limit_per_minute: u32,
#[serde(default = "default_zombie_timeout")]
pub zombie_timeout_secs: u64,
}
fn default_max_concurrent() -> usize {
5
}
fn default_rate_limit() -> u32 {
60
}
fn default_zombie_timeout() -> u64 {
300
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
max_concurrent: default_max_concurrent(),
rate_limit_per_minute: default_rate_limit(),
zombie_timeout_secs: default_zombie_timeout(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OrchestratorConfig {
#[serde(default = "default_max_evolution_iterations")]
pub max_evolution_iterations: usize,
#[serde(default = "default_min_evaluation_score")]
pub min_evaluation_score: f64,
}
fn default_max_evolution_iterations() -> usize {
3
}
fn default_min_evaluation_score() -> f64 {
0.8
}
impl Default for OrchestratorConfig {
fn default() -> Self {
Self {
max_evolution_iterations: default_max_evolution_iterations(),
min_evaluation_score: default_min_evaluation_score(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ContextConfig {
#[serde(default = "default_active_limit")]
pub active_limit_tokens: usize,
#[serde(default = "default_cache_limit")]
pub cache_limit_entries: usize,
}
fn default_active_limit() -> usize {
100_000
}
fn default_cache_limit() -> usize {
50
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
active_limit_tokens: default_active_limit(),
cache_limit_entries: default_cache_limit(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecurityConfig {
#[serde(default = "default_allowed_tools")]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub network_access: bool,
#[serde(default = "default_max_exec_time")]
pub max_execution_time_secs: u64,
#[serde(default = "default_max_memory")]
pub max_memory_mb: u64,
#[serde(default)]
pub can_fork: bool,
#[serde(default = "default_max_audit")]
pub max_audit_entries: usize,
#[serde(default)]
pub auth_enabled: bool,
#[serde(default = "default_cors_origins")]
pub cors_origins: Vec<String>,
#[serde(default)]
pub audit_log_path: Option<String>,
#[serde(default = "default_rate_limit_per_minute")]
pub rate_limit_per_minute: u32,
}
fn default_allowed_tools() -> Vec<String> {
vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"grep".to_string(),
"find".to_string(),
]
}
fn default_max_exec_time() -> u64 {
300
}
fn default_max_memory() -> u64 {
512
}
fn default_max_audit() -> usize {
10_000
}
fn default_rate_limit_per_minute() -> u32 {
120
}
fn default_cors_origins() -> Vec<String> {
vec!["http://localhost:4200".to_string()]
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
allowed_tools: default_allowed_tools(),
network_access: false,
max_execution_time_secs: default_max_exec_time(),
max_memory_mb: default_max_memory(),
can_fork: false,
max_audit_entries: default_max_audit(),
auth_enabled: false,
cors_origins: default_cors_origins(),
audit_log_path: None,
rate_limit_per_minute: default_rate_limit_per_minute(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PersonaConfig {
#[serde(default)]
pub default_persona_id: Option<String>,
#[serde(default = "default_max_concurrent_personas")]
pub max_concurrent_personas: usize,
}
fn default_max_concurrent_personas() -> usize {
5
}
impl Default for PersonaConfig {
fn default() -> Self {
Self {
default_persona_id: Some("dev".to_string()),
max_concurrent_personas: default_max_concurrent_personas(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct McpConfig {
#[serde(default)]
pub servers: std::collections::HashMap<String, McpServerDef>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerDef {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
#[serde(default = "default_mcp_enabled")]
pub enabled: bool,
}
fn default_mcp_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitConfig {
#[serde(default = "default_true")]
pub auto_commit: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self { auto_commit: true }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuditConfig {
#[serde(default = "default_audit_max_entries")]
pub max_entries: usize,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_audit_max_entries() -> usize {
100_000
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
max_entries: default_audit_max_entries(),
enabled: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BudgetConfig {
#[serde(default)]
pub default_token_budget: u64,
#[serde(default)]
pub default_calls_budget: u64,
#[serde(default = "default_budget_window")]
pub default_window_secs: u64,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_budget_window() -> u64 {
3600
}
impl Default for BudgetConfig {
fn default() -> Self {
Self {
default_token_budget: 0,
default_calls_budget: 0,
default_window_secs: default_budget_window(),
enabled: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ResourceMonitorConfig {
#[serde(default = "default_rm_interval")]
pub interval_secs: u64,
#[serde(default = "default_rm_history_max")]
pub history_max: usize,
#[serde(default = "default_rm_cpu_threshold")]
pub cpu_threshold: f32,
#[serde(default = "default_rm_mem_threshold")]
pub memory_threshold: f32,
#[serde(default = "default_rm_load_threshold")]
pub load_threshold: f32,
}
fn default_rm_interval() -> u64 {
60
}
fn default_rm_history_max() -> usize {
60
}
fn default_rm_cpu_threshold() -> f32 {
90.0
}
fn default_rm_mem_threshold() -> f32 {
90.0
}
fn default_rm_load_threshold() -> f32 {
8.0
}
impl Default for ResourceMonitorConfig {
fn default() -> Self {
Self {
interval_secs: default_rm_interval(),
history_max: default_rm_history_max(),
cpu_threshold: default_rm_cpu_threshold(),
memory_threshold: default_rm_mem_threshold(),
load_threshold: default_rm_load_threshold(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OtelConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_otel_endpoint")]
pub endpoint: String,
#[serde(default = "default_otel_service_name")]
pub service_name: String,
#[serde(default = "default_otel_sampling_ratio")]
pub sampling_ratio: f64,
}
fn default_otel_endpoint() -> String {
"http://localhost:4317".into()
}
fn default_otel_service_name() -> String {
"oxios".into()
}
fn default_otel_sampling_ratio() -> f64 {
1.0
}
impl Default for OtelConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: default_otel_endpoint(),
service_name: default_otel_service_name(),
sampling_ratio: default_otel_sampling_ratio(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_format")]
pub format: String,
#[serde(default)]
pub level: Option<String>,
}
fn default_log_format() -> String {
"pretty".into()
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
format: default_log_format(),
level: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BrowserConfig {
#[serde(default = "default_browser_enabled")]
pub enabled: bool,
#[serde(default)]
pub engine: oxibrowser_core::BrowserConfig,
}
fn default_browser_enabled() -> bool {
true
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: true,
engine: oxibrowser_core::BrowserConfig::headless(),
}
}
}
pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
let content = std::fs::read_to_string(path)?;
let config: OxiosConfig = toml::from_str(&content)?;
let (errors, warnings) = config.validate();
for w in warnings {
tracing::warn!("config: {}", w);
}
if !errors.is_empty() {
let msg = errors.join("; ");
anyhow::bail!("Configuration validation failed: {}", msg);
}
Ok(config)
}
impl OxiosConfig {
pub fn api_key(&self) -> Option<String> {
self.engine.api_key.clone().filter(|k| !k.is_empty())
}
pub fn validate(&self) -> (Vec<String>, Vec<String>) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
if self.kernel.max_agents == 0 {
errors.push("kernel.max_agents must be > 0".into());
}
if self.kernel.workspace.is_empty() {
errors.push("kernel.workspace must not be empty".into());
}
if self.gateway.port == 0 {
errors.push("gateway.port must be > 0".into());
}
if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
}
if self.scheduler.max_concurrent == 0 {
warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
}
if self.scheduler.zombie_timeout_secs == 0 {
errors.push("scheduler.zombie_timeout_secs must be > 0".into());
}
for (name, job) in &self.cron.jobs {
if job.schedule.is_empty() {
errors.push(format!("cron.jobs.{}: schedule is empty", name));
} else {
let normalized = {
let fields: Vec<&str> = job.schedule.split_whitespace().collect();
match fields.len() {
5 => format!("0 {}", job.schedule),
_ => job.schedule.clone(),
}
};
if Schedule::from_str(&normalized).is_err() {
errors.push(format!(
"cron.jobs.{}: invalid cron expression '{}'",
name, job.schedule
));
}
}
if job.goal.is_empty() {
errors.push(format!("cron.jobs.{}: goal is empty", name));
}
}
if self.security.max_execution_time_secs == 0 {
warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
}
if self.audit.max_entries == 0 {
warnings.push("audit.max_entries is 0 — audit will never prune".into());
}
if self.budget.default_window_secs == 0 {
warnings.push("budget.default_window_secs is 0 — no time window".into());
}
if self.exec.default_timeout_secs == 0 {
errors.push("exec.default_timeout_secs must be > 0".into());
}
if self.exec.max_timeout_secs == 0 {
errors.push("exec.max_timeout_secs must be > 0".into());
}
if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
errors.push(format!(
"exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
self.exec.default_timeout_secs, self.exec.max_timeout_secs
));
}
if self.resource_monitor.cpu_threshold > 100.0 {
errors.push("resource_monitor.cpu_threshold must be <= 100".into());
}
if self.resource_monitor.memory_threshold > 100.0 {
errors.push("resource_monitor.memory_threshold must be <= 100".into());
}
for name in &self.channels.enabled {
let valid = ["web", "cli", "telegram"];
if !valid.contains(&name.as_str()) {
warnings.push(format!("channels.enabled: unknown channel '{}'", name));
}
}
if self.channels.enabled.iter().any(|c| c == "telegram")
&& std::env::var(&self.channels.telegram.bot_token_env).is_err()
{
warnings.push(format!(
"channels.telegram: {} env var not set — telegram channel will fail",
self.channels.telegram.bot_token_env
));
}
(errors, warnings)
}
}
pub fn expand_home(path: &str) -> std::path::PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return std::path::PathBuf::from(format!("{home}/{rest}"));
}
}
std::path::PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_validates() {
let config = OxiosConfig::default();
let (errors, _warnings) = config.validate();
assert!(
errors.is_empty(),
"Default config should have no errors: {:?}",
errors
);
}
#[test]
fn test_exec_config_default_allowed_commands() {
let config = ExecConfig::default();
assert!(config.allowed_commands.is_empty());
assert!(config.is_binary_allowed("anything"));
assert!(config.is_binary_allowed("bash"));
assert!(config.is_binary_allowed("rm"));
}
#[test]
fn test_is_binary_allowed_with_allowlist() {
let config = ExecConfig {
allowed_commands: vec!["git".into(), "echo".into()],
..Default::default()
};
assert!(config.is_binary_allowed("git"));
assert!(config.is_binary_allowed("echo"));
assert!(!config.is_binary_allowed("bash"));
assert!(!config.is_binary_allowed("rm"));
assert!(!config.is_binary_allowed("sudo"));
}
#[test]
fn test_expand_home() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
let expanded = expand_home("~/projects/test");
assert_eq!(
expanded.to_str().unwrap(),
format!("{}/projects/test", home)
);
let abs = expand_home("/absolute/path");
assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
let bare = expand_home("~something");
assert_eq!(bare, std::path::PathBuf::from("~something"));
}
#[test]
fn test_invalid_cron_expression() {
let mut config = OxiosConfig::default();
config.cron.enabled = true;
config.cron.jobs.insert(
"bad-job".to_string(),
InlineCronJob {
schedule: "not a valid cron".to_string(),
goal: "Test goal".to_string(),
constraints: vec![],
acceptance_criteria: vec![],
toolchain: "default".to_string(),
priority: Priority::Normal,
enabled: true,
},
);
let (errors, _warnings) = config.validate();
assert!(
!errors.is_empty(),
"Expected validation error for invalid cron"
);
let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
assert!(
has_cron_error,
"Expected 'invalid cron expression' error, got: {:?}",
errors
);
}
#[test]
fn test_config_serialization_roundtrip() {
let config = OxiosConfig::default();
let toml_str = toml::to_string(&config).expect("serialization should succeed");
let deserialized: OxiosConfig =
toml::from_str(&toml_str).expect("deserialization should succeed");
assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
assert_eq!(config.gateway.host, deserialized.gateway.host);
assert_eq!(config.gateway.port, deserialized.gateway.port);
assert_eq!(
config.exec.default_timeout_secs,
deserialized.exec.default_timeout_secs
);
assert_eq!(
config.exec.max_timeout_secs,
deserialized.exec.max_timeout_secs
);
}
#[test]
fn test_exec_timeout_validation() {
let mut config = OxiosConfig::default();
config.exec.default_timeout_secs = 999;
config.exec.max_timeout_secs = 100;
let (errors, _warnings) = config.validate();
let has_error = errors.iter().any(|e| e.contains("must not exceed"));
assert!(
has_error,
"Expected timeout ordering error, got: {:?}",
errors
);
}
#[test]
fn test_zero_max_agents_error() {
let mut config = OxiosConfig::default();
config.kernel.max_agents = 0;
let (errors, _warnings) = config.validate();
assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
}
}