Skip to main content

ares/utils/
toon_config.rs

1//! TOON-based dynamic configuration for A.R.E.S
2//!
3//! This module handles hot-reloadable behavioral configuration:
4//! - Agents
5//! - Workflows
6//! - Models
7//! - Tools
8//! - MCP servers
9//!
10//! # Architecture
11//!
12//! ARES uses a hybrid configuration approach:
13//! - **TOML** (`ares.toml`): Static infrastructure config (server, auth, database, providers)
14//! - **TOON** (`config/*.toon`): Dynamic behavioral config (agents, workflows, models, tools, MCPs)
15//!
16//! This separation achieves:
17//! 1. Separation of concerns: Infrastructure vs. behavior
18//! 2. Token efficiency: TOON reduces LLM context usage by 30-60%
19//! 3. Hot-reloadability: Behavioral configs can change without restarts
20//! 4. LLM-friendliness: TOON is optimized for AI consumption
21//!
22//! # Example Agent Config (`config/agents/router.toon`)
23//!
24//! ```toon
25//! name: router
26//! model: fast
27//! max_tool_iterations: 1
28//! parallel_tools: false
29//! tools[0]:
30//! system_prompt: |
31//!   You are a routing agent...
32//! ```
33
34use arc_swap::ArcSwap;
35use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::fs;
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41use toon_format::{decode_default, encode_default, ToonError};
42use tracing::{debug, error, info, warn};
43
44// ============= Agent Configuration =============
45
46/// Configuration for an AI agent loaded from TOON files
47///
48/// Agents are the core behavioral units in ARES. Each agent has:
49/// - A model reference (defined in `config/models/*.toon`)
50/// - A system prompt defining its behavior
51/// - Optional tools it can use
52/// - Iteration limits for tool calling
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct ToonAgentConfig {
55    /// Unique identifier for the agent
56    pub name: String,
57
58    /// Semantic version of this config (e.g. "1.0.0")
59    /// Increment on any behavior-changing edit. Stored in agent_config_versions on every change.
60    #[serde(default = "default_version")]
61    pub version: String,
62
63    /// Reference to a model name defined in `config/models/`
64    pub model: String,
65
66    /// System prompt defining agent behavior
67    #[serde(default)]
68    pub system_prompt: Option<String>,
69
70    /// List of tool names this agent can use (defined in `config/tools/`)
71    #[serde(default)]
72    pub tools: Vec<String>,
73
74    /// Maximum tool calling iterations before returning
75    #[serde(default = "default_max_tool_iterations")]
76    pub max_tool_iterations: usize,
77
78    /// Whether to execute multiple tool calls in parallel
79    #[serde(default)]
80    pub parallel_tools: bool,
81
82    /// Additional agent-specific configuration (extensible)
83    #[serde(flatten)]
84    pub extra: HashMap<String, serde_json::Value>,
85}
86
87fn default_version() -> String {
88    "0.1.0".to_string()
89}
90
91fn default_max_tool_iterations() -> usize {
92    10
93}
94
95impl ToonAgentConfig {
96    /// Create a new agent config with required fields
97    pub fn new(name: impl Into<String>, model: impl Into<String>) -> Self {
98        Self {
99            name: name.into(),
100            version: default_version(),
101            model: model.into(),
102            system_prompt: None,
103            tools: Vec::new(),
104            max_tool_iterations: default_max_tool_iterations(),
105            parallel_tools: false,
106            extra: HashMap::new(),
107        }
108    }
109
110    /// Set the system prompt
111    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
112        self.system_prompt = Some(prompt.into());
113        self
114    }
115
116    /// Set the tools list
117    pub fn with_tools(mut self, tools: Vec<String>) -> Self {
118        self.tools = tools;
119        self
120    }
121
122    /// Encode this config to TOON format
123    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
124        encode_default(self).map_err(ToonConfigError::from)
125    }
126
127    /// Parse an agent config from TOON format
128    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
129        decode_default(toon).map_err(ToonConfigError::from)
130    }
131}
132
133// ============= Model Configuration =============
134
135/// Configuration for an LLM model loaded from TOON files
136///
137/// Models reference providers defined in `ares.toml` and specify
138/// inference parameters like temperature and token limits.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct ToonModelConfig {
141    /// Unique identifier for the model configuration
142    pub name: String,
143
144    /// Reference to a provider name defined in `ares.toml` [providers.*]
145    pub provider: String,
146
147    /// Model name/identifier to use with the provider (e.g., "gpt-4", "ministral-3:3b")
148    pub model: String,
149
150    /// Sampling temperature (0.0 = deterministic, 1.0+ = creative)
151    #[serde(default = "default_temperature")]
152    pub temperature: f32,
153
154    /// Maximum tokens to generate
155    #[serde(default = "default_max_tokens")]
156    pub max_tokens: u32,
157
158    /// Optional nucleus sampling parameter
159    #[serde(default)]
160    pub top_p: Option<f32>,
161
162    /// Optional frequency penalty (-2.0 to 2.0)
163    #[serde(default)]
164    pub frequency_penalty: Option<f32>,
165
166    /// Optional presence penalty (-2.0 to 2.0)
167    #[serde(default)]
168    pub presence_penalty: Option<f32>,
169}
170
171fn default_temperature() -> f32 {
172    0.7
173}
174
175fn default_max_tokens() -> u32 {
176    512
177}
178
179impl ToonModelConfig {
180    /// Create a new model config with required fields
181    pub fn new(
182        name: impl Into<String>,
183        provider: impl Into<String>,
184        model: impl Into<String>,
185    ) -> Self {
186        Self {
187            name: name.into(),
188            provider: provider.into(),
189            model: model.into(),
190            temperature: default_temperature(),
191            max_tokens: default_max_tokens(),
192            top_p: None,
193            frequency_penalty: None,
194            presence_penalty: None,
195        }
196    }
197
198    /// Encode this config to TOON format
199    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
200        encode_default(self).map_err(ToonConfigError::from)
201    }
202
203    /// Parse a model config from TOON format
204    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
205        decode_default(toon).map_err(ToonConfigError::from)
206    }
207}
208
209// ============= Tool Configuration =============
210
211/// Configuration for a tool loaded from TOON files
212///
213/// Tools provide external capabilities to agents (calculator, web search, etc.)
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215pub struct ToonToolConfig {
216    /// Unique identifier for the tool
217    pub name: String,
218
219    /// Whether this tool is currently enabled
220    #[serde(default = "default_true")]
221    pub enabled: bool,
222
223    /// Human-readable description of what the tool does
224    #[serde(default)]
225    pub description: Option<String>,
226
227    /// Timeout in seconds for tool execution
228    #[serde(default = "default_timeout")]
229    pub timeout_secs: u64,
230
231    /// Additional tool-specific configuration
232    #[serde(flatten)]
233    pub extra: HashMap<String, serde_json::Value>,
234}
235
236fn default_true() -> bool {
237    true
238}
239
240fn default_timeout() -> u64 {
241    30
242}
243
244impl ToonToolConfig {
245    /// Create a new tool config with required fields
246    pub fn new(name: impl Into<String>) -> Self {
247        Self {
248            name: name.into(),
249            enabled: default_true(),
250            description: None,
251            timeout_secs: default_timeout(),
252            extra: HashMap::new(),
253        }
254    }
255
256    /// Encode this config to TOON format
257    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
258        encode_default(self).map_err(ToonConfigError::from)
259    }
260
261    /// Parse a tool config from TOON format
262    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
263        decode_default(toon).map_err(ToonConfigError::from)
264    }
265}
266
267// ============= Workflow Configuration =============
268
269/// Configuration for a workflow loaded from TOON files
270///
271/// Workflows define how agents work together to handle complex requests.
272/// They specify entry points, fallbacks, and iteration limits.
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274pub struct ToonWorkflowConfig {
275    /// Unique identifier for the workflow
276    pub name: String,
277
278    /// The agent that first receives requests
279    pub entry_agent: String,
280
281    /// Agent to use if routing/entry fails
282    #[serde(default)]
283    pub fallback_agent: Option<String>,
284
285    /// Maximum depth for recursive agent calls
286    #[serde(default = "default_max_depth")]
287    pub max_depth: u8,
288
289    /// Maximum total iterations across all agents
290    #[serde(default = "default_max_iterations")]
291    pub max_iterations: u8,
292
293    /// Whether to run subagents in parallel when possible
294    #[serde(default)]
295    pub parallel_subagents: bool,
296}
297
298fn default_max_depth() -> u8 {
299    3
300}
301
302fn default_max_iterations() -> u8 {
303    5
304}
305
306impl ToonWorkflowConfig {
307    /// Create a new workflow config with required fields
308    pub fn new(name: impl Into<String>, entry_agent: impl Into<String>) -> Self {
309        Self {
310            name: name.into(),
311            entry_agent: entry_agent.into(),
312            fallback_agent: None,
313            max_depth: default_max_depth(),
314            max_iterations: default_max_iterations(),
315            parallel_subagents: false,
316        }
317    }
318
319    /// Encode this config to TOON format
320    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
321        encode_default(self).map_err(ToonConfigError::from)
322    }
323
324    /// Parse a workflow config from TOON format
325    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
326        decode_default(toon).map_err(ToonConfigError::from)
327    }
328}
329
330// ============= MCP Server Configuration =============
331
332/// Configuration for an MCP (Model Context Protocol) server
333///
334/// MCP servers provide additional capabilities to agents via a standardized protocol.
335/// See: <https://modelcontextprotocol.io/>
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
337pub struct ToonMcpConfig {
338    /// Unique identifier for the MCP server
339    pub name: String,
340
341    /// Whether this MCP server is currently enabled
342    #[serde(default = "default_true")]
343    pub enabled: bool,
344
345    /// Command to run the MCP server (e.g., "npx", "python"). Optional for HTTP transport.
346    #[serde(default)]
347    pub command: Option<String>,
348
349    /// Arguments to pass to the command
350    #[serde(default)]
351    pub args: Vec<String>,
352
353    /// Environment variables to set for the MCP server
354    #[serde(default)]
355    pub env: HashMap<String, String>,
356
357    /// Timeout in seconds for MCP operations
358    #[serde(default = "default_timeout")]
359    pub timeout_secs: u64,
360}
361
362impl ToonMcpConfig {
363    /// Create a new MCP config with required fields
364    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
365        Self {
366            name: name.into(),
367            enabled: default_true(),
368            command: Some(command.into()),
369            args: Vec::new(),
370            env: HashMap::new(),
371            timeout_secs: default_timeout(),
372        }
373    }
374
375    /// Encode this config to TOON format
376    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
377        encode_default(self).map_err(ToonConfigError::from)
378    }
379
380    /// Parse an MCP config from TOON format
381    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
382        decode_default(toon).map_err(ToonConfigError::from)
383    }
384}
385
386// ============= Dynamic Config Aggregate =============
387
388/// Aggregated dynamic configuration from all TOON files
389///
390/// This struct holds all behavioral configuration loaded from the
391/// `config/` directory tree. It is wrapped in `ArcSwap` for
392/// lock-free concurrent access with atomic updates during hot-reload.
393#[derive(Debug, Clone, Default)]
394pub struct DynamicConfig {
395    /// Agent configurations keyed by name
396    pub agents: HashMap<String, ToonAgentConfig>,
397    /// Model configurations keyed by name
398    pub models: HashMap<String, ToonModelConfig>,
399    /// Tool configurations keyed by name
400    pub tools: HashMap<String, ToonToolConfig>,
401    /// Workflow configurations keyed by name
402    pub workflows: HashMap<String, ToonWorkflowConfig>,
403    /// MCP server configurations keyed by name
404    pub mcps: HashMap<String, ToonMcpConfig>,
405}
406
407impl DynamicConfig {
408    /// Load all TOON configs from directories
409    pub fn load(
410        agents_dir: &Path,
411        models_dir: &Path,
412        tools_dir: &Path,
413        workflows_dir: &Path,
414        mcps_dir: &Path,
415    ) -> Result<Self, ToonConfigError> {
416        let agents = load_configs_from_dir::<ToonAgentConfig>(agents_dir, "agents")?;
417        let models = load_configs_from_dir::<ToonModelConfig>(models_dir, "models")?;
418        let tools = load_configs_from_dir::<ToonToolConfig>(tools_dir, "tools")?;
419        let workflows = load_configs_from_dir::<ToonWorkflowConfig>(workflows_dir, "workflows")?;
420        let mcps = load_configs_from_dir::<ToonMcpConfig>(mcps_dir, "mcps")?;
421
422        info!(
423            "Loaded dynamic config: {} agents, {} models, {} tools, {} workflows, {} mcps",
424            agents.len(),
425            models.len(),
426            tools.len(),
427            workflows.len(),
428            mcps.len()
429        );
430
431        Ok(Self {
432            agents,
433            models,
434            tools,
435            workflows,
436            mcps,
437        })
438    }
439
440    /// Get an agent config by name
441    pub fn get_agent(&self, name: &str) -> Option<&ToonAgentConfig> {
442        self.agents.get(name)
443    }
444
445    /// Get a model config by name
446    pub fn get_model(&self, name: &str) -> Option<&ToonModelConfig> {
447        self.models.get(name)
448    }
449
450    /// Get a tool config by name
451    pub fn get_tool(&self, name: &str) -> Option<&ToonToolConfig> {
452        self.tools.get(name)
453    }
454
455    /// Get a workflow config by name
456    pub fn get_workflow(&self, name: &str) -> Option<&ToonWorkflowConfig> {
457        self.workflows.get(name)
458    }
459
460    /// Get an MCP config by name
461    pub fn get_mcp(&self, name: &str) -> Option<&ToonMcpConfig> {
462        self.mcps.get(name)
463    }
464
465    /// Get all agent names
466    pub fn agent_names(&self) -> Vec<&str> {
467        self.agents.keys().map(|s| s.as_str()).collect()
468    }
469
470    /// Get all model names
471    pub fn model_names(&self) -> Vec<&str> {
472        self.models.keys().map(|s| s.as_str()).collect()
473    }
474
475    /// Get all tool names
476    pub fn tool_names(&self) -> Vec<&str> {
477        self.tools.keys().map(|s| s.as_str()).collect()
478    }
479
480    /// Get all workflow names
481    pub fn workflow_names(&self) -> Vec<&str> {
482        self.workflows.keys().map(|s| s.as_str()).collect()
483    }
484
485    /// Get all MCP names
486    pub fn mcp_names(&self) -> Vec<&str> {
487        self.mcps.keys().map(|s| s.as_str()).collect()
488    }
489
490    /// Validate the configuration for internal consistency
491    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ToonConfigError> {
492        let mut warnings = Vec::new();
493
494        // Validate agent -> model references
495        for (agent_name, agent) in &self.agents {
496            if !self.models.contains_key(&agent.model) {
497                return Err(ToonConfigError::Validation(format!(
498                    "Agent '{}' references unknown model '{}'",
499                    agent_name, agent.model
500                )));
501            }
502
503            // Validate agent -> tools references
504            for tool_name in &agent.tools {
505                if !self.tools.contains_key(tool_name) {
506                    return Err(ToonConfigError::Validation(format!(
507                        "Agent '{}' references unknown tool '{}'",
508                        agent_name, tool_name
509                    )));
510                }
511            }
512        }
513
514        // Validate workflow -> agent references
515        for (workflow_name, workflow) in &self.workflows {
516            if !self.agents.contains_key(&workflow.entry_agent) {
517                return Err(ToonConfigError::Validation(format!(
518                    "Workflow '{}' references unknown entry agent '{}'",
519                    workflow_name, workflow.entry_agent
520                )));
521            }
522
523            if let Some(ref fallback) = workflow.fallback_agent {
524                if !self.agents.contains_key(fallback) {
525                    return Err(ToonConfigError::Validation(format!(
526                        "Workflow '{}' references unknown fallback agent '{}'",
527                        workflow_name, fallback
528                    )));
529                }
530            }
531        }
532
533        // Check for unused models
534        let used_models: std::collections::HashSet<_> =
535            self.agents.values().map(|a| &a.model).collect();
536        for model_name in self.models.keys() {
537            if !used_models.contains(model_name) {
538                warnings.push(ConfigWarning {
539                    kind: WarningKind::UnusedModel,
540                    message: format!("Model '{}' is not used by any agent", model_name),
541                });
542            }
543        }
544
545        // Check for unused tools
546        let used_tools: std::collections::HashSet<_> =
547            self.agents.values().flat_map(|a| a.tools.iter()).collect();
548        for tool_name in self.tools.keys() {
549            if !used_tools.contains(tool_name) {
550                warnings.push(ConfigWarning {
551                    kind: WarningKind::UnusedTool,
552                    message: format!("Tool '{}' is not used by any agent", tool_name),
553                });
554            }
555        }
556
557        Ok(warnings)
558    }
559}
560
561// ============= Config Loading Helpers =============
562
563/// Trait for config types that have a name field.
564///
565/// All TOON config types must implement this trait to enable
566/// automatic keying by name when loading from directories.
567pub trait HasName {
568    /// Returns the unique name/identifier of this configuration.
569    fn name(&self) -> &str;
570}
571
572impl HasName for ToonAgentConfig {
573    fn name(&self) -> &str {
574        &self.name
575    }
576}
577
578impl HasName for ToonModelConfig {
579    fn name(&self) -> &str {
580        &self.name
581    }
582}
583
584impl HasName for ToonToolConfig {
585    fn name(&self) -> &str {
586        &self.name
587    }
588}
589
590impl HasName for ToonWorkflowConfig {
591    fn name(&self) -> &str {
592        &self.name
593    }
594}
595
596impl HasName for ToonMcpConfig {
597    fn name(&self) -> &str {
598        &self.name
599    }
600}
601
602/// Load all .toon files from a directory into a HashMap keyed by name
603fn load_configs_from_dir<T>(
604    dir: &Path,
605    config_type: &str,
606) -> Result<HashMap<String, T>, ToonConfigError>
607where
608    T: for<'de> Deserialize<'de> + HasName,
609{
610    let mut configs = HashMap::new();
611
612    if !dir.exists() {
613        debug!("Config directory does not exist: {:?}", dir);
614        return Ok(configs);
615    }
616
617    let entries = fs::read_dir(dir).map_err(|e| {
618        ToonConfigError::Io(std::io::Error::new(
619            e.kind(),
620            format!("Failed to read {} directory {:?}: {}", config_type, dir, e),
621        ))
622    })?;
623
624    for entry in entries {
625        let entry = entry.map_err(ToonConfigError::Io)?;
626        let path = entry.path();
627
628        // Only process .toon files
629        if path.extension().and_then(|e| e.to_str()) != Some("toon") {
630            continue;
631        }
632
633        match load_toon_file::<T>(&path) {
634            Ok(config) => {
635                let name = config.name().to_string();
636                debug!("Loaded {} config: {}", config_type, name);
637                configs.insert(name, config);
638            }
639            Err(e) => {
640                warn!("Failed to load {} from {:?}: {}", config_type, path, e);
641            }
642        }
643    }
644
645    Ok(configs)
646}
647
648/// Load a single TOON file and deserialize it
649fn load_toon_file<T>(path: &Path) -> Result<T, ToonConfigError>
650where
651    T: for<'de> Deserialize<'de>,
652{
653    let content = fs::read_to_string(path).map_err(|e| {
654        ToonConfigError::Io(std::io::Error::new(
655            e.kind(),
656            format!("Failed to read {:?}: {}", path, e),
657        ))
658    })?;
659
660    decode_default(&content)
661        .map_err(|e| ToonConfigError::Parse(format!("Failed to parse {:?}: {}", path, e)))
662}
663
664// ============= Error Types =============
665
666/// Errors that can occur during TOON configuration loading.
667#[derive(Debug, thiserror::Error)]
668pub enum ToonConfigError {
669    /// An I/O error occurred while reading configuration files.
670    #[error("IO error: {0}")]
671    Io(#[from] std::io::Error),
672
673    /// Failed to parse TOON format content.
674    #[error("TOON parse error: {0}")]
675    Parse(String),
676
677    /// Configuration validation failed (e.g., missing references).
678    #[error("Validation error: {0}")]
679    Validation(String),
680
681    /// An error occurred while watching configuration files for changes.
682    #[error("Watch error: {0}")]
683    Watch(#[from] notify::Error),
684}
685
686impl From<ToonError> for ToonConfigError {
687    fn from(e: ToonError) -> Self {
688        ToonConfigError::Parse(e.to_string())
689    }
690}
691
692/// Non-fatal configuration warnings.
693#[derive(Debug, Clone)]
694pub struct ConfigWarning {
695    /// Category of the warning.
696    pub kind: WarningKind,
697
698    /// Human-readable warning message.
699    pub message: String,
700}
701
702/// Categories of TOON configuration warnings.
703#[derive(Debug, Clone, PartialEq)]
704pub enum WarningKind {
705    /// A model is defined but not referenced by any agent.
706    UnusedModel,
707
708    /// A tool is defined but not referenced by any agent.
709    UnusedTool,
710
711    /// An agent is defined but not used in any workflow.
712    UnusedAgent,
713
714    /// A workflow is defined but not the default or referenced.
715    UnusedWorkflow,
716
717    /// An MCP server is defined but not referenced.
718    UnusedMcp,
719}
720
721impl std::fmt::Display for ConfigWarning {
722    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723        write!(f, "{}", self.message)
724    }
725}
726
727// ============= Hot Reload Manager =============
728
729/// Manager for dynamic TOON configuration with hot-reload support
730///
731/// This manager:
732/// - Loads all TOON configs at startup
733/// - Watches config directories for changes
734/// - Atomically swaps config on changes (lock-free reads)
735/// - Provides convenient accessor methods
736///
737/// # Example
738///
739/// ```rust,ignore
740/// let manager = DynamicConfigManager::new(
741///     PathBuf::from("config/agents"),
742///     PathBuf::from("config/models"),
743///     PathBuf::from("config/tools"),
744///     PathBuf::from("config/workflows"),
745///     PathBuf::from("config/mcps"),
746///     true, // hot_reload
747/// )?;
748///
749/// // Get an agent config (lock-free)
750/// if let Some(router) = manager.agent("router") {
751///     println!("Router uses model: {}", router.model);
752/// }
753/// ```
754pub struct DynamicConfigManager {
755    config: Arc<ArcSwap<DynamicConfig>>,
756    agents_dir: PathBuf,
757    models_dir: PathBuf,
758    tools_dir: PathBuf,
759    workflows_dir: PathBuf,
760    mcps_dir: PathBuf,
761    _watcher: Option<RecommendedWatcher>,
762    /// Shared sender for version change events. Populated via `set_version_tx`.
763    /// The watcher closure holds a clone of this Arc, so setting it after construction
764    /// is visible inside the watcher.
765    version_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>>,
766}
767
768impl DynamicConfigManager {
769    /// Create DynamicConfigManager from AresConfig
770    ///
771    /// This uses the paths defined in `config.config` (DynamicConfigPaths)
772    /// to initialize the manager.
773    pub fn from_config(
774        config: &crate::utils::toml_config::AresConfig,
775    ) -> Result<Self, ToonConfigError> {
776        let agents_dir = PathBuf::from(&config.config.agents_dir);
777        let models_dir = PathBuf::from(&config.config.models_dir);
778        let tools_dir = PathBuf::from(&config.config.tools_dir);
779        let workflows_dir = PathBuf::from(&config.config.workflows_dir);
780        let mcps_dir = PathBuf::from(&config.config.mcps_dir);
781
782        Self::new(
783            agents_dir,
784            models_dir,
785            tools_dir,
786            workflows_dir,
787            mcps_dir,
788            true, // Enable hot reload by default
789        )
790    }
791
792    /// Create a new DynamicConfigManager
793    ///
794    /// # Arguments
795    /// * `agents_dir` - Directory containing agent TOON files
796    /// * `models_dir` - Directory containing model TOON files
797    /// * `tools_dir` - Directory containing tool TOON files
798    /// * `workflows_dir` - Directory containing workflow TOON files
799    /// * `mcps_dir` - Directory containing MCP TOON files
800    /// * `hot_reload` - Whether to watch for file changes
801    pub fn new(
802        agents_dir: PathBuf,
803        models_dir: PathBuf,
804        tools_dir: PathBuf,
805        workflows_dir: PathBuf,
806        mcps_dir: PathBuf,
807        hot_reload: bool,
808    ) -> Result<Self, ToonConfigError> {
809        // Load initial config
810        let initial_config = DynamicConfig::load(
811            &agents_dir,
812            &models_dir,
813            &tools_dir,
814            &workflows_dir,
815            &mcps_dir,
816        )?;
817
818        let config = Arc::new(ArcSwap::from_pointee(initial_config));
819
820        // Shared version channel — populated after construction via set_version_tx
821        let version_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>> =
822            Arc::new(std::sync::Mutex::new(None));
823
824        // Set up file watcher if hot reload is enabled
825        let watcher = if hot_reload {
826            Some(Self::setup_watcher(
827                config.clone(),
828                agents_dir.clone(),
829                models_dir.clone(),
830                tools_dir.clone(),
831                workflows_dir.clone(),
832                mcps_dir.clone(),
833                version_tx.clone(),
834            )?)
835        } else {
836            None
837        };
838
839        Ok(Self {
840            config,
841            agents_dir,
842            models_dir,
843            tools_dir,
844            workflows_dir,
845            mcps_dir,
846            _watcher: watcher,
847            version_tx,
848        })
849    }
850
851    /// Attach a version tracking sender. After this call, every hot-reload emits the
852    /// updated agent list to this channel for a background task to persist to DB.
853    pub fn set_version_tx(&self, tx: tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>) {
854        if let Ok(mut guard) = self.version_tx.lock() {
855            *guard = Some(tx);
856        }
857    }
858
859    /// Set up file watcher for hot-reload
860    fn setup_watcher(
861        config: Arc<ArcSwap<DynamicConfig>>,
862        agents_dir: PathBuf,
863        models_dir: PathBuf,
864        tools_dir: PathBuf,
865        workflows_dir: PathBuf,
866        mcps_dir: PathBuf,
867        version_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>>,
868    ) -> Result<RecommendedWatcher, ToonConfigError> {
869        let agents_dir_clone = agents_dir.clone();
870        let models_dir_clone = models_dir.clone();
871        let tools_dir_clone = tools_dir.clone();
872        let workflows_dir_clone = workflows_dir.clone();
873        let mcps_dir_clone = mcps_dir.clone();
874
875        let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
876            match res {
877                Ok(event) => {
878                    // Only reload on create, modify, or remove events
879                    if matches!(
880                        event.kind,
881                        notify::EventKind::Create(_)
882                            | notify::EventKind::Modify(_)
883                            | notify::EventKind::Remove(_)
884                    ) {
885                        info!("Config change detected, reloading...");
886
887                        match DynamicConfig::load(
888                            &agents_dir_clone,
889                            &models_dir_clone,
890                            &tools_dir_clone,
891                            &workflows_dir_clone,
892                            &mcps_dir_clone,
893                        ) {
894                            Ok(new_config) => {
895                                // Validate before swapping
896                                match new_config.validate() {
897                                    Ok(warnings) => {
898                                        for warning in warnings {
899                                            warn!("Config warning: {}", warning);
900                                        }
901                                        // Emit version change event before swapping
902                                        let agents: Vec<ToonAgentConfig> =
903                                            new_config.agents.values().cloned().collect();
904                                        if let Ok(guard) = version_tx.lock() {
905                                            if let Some(tx) = guard.as_ref() {
906                                                let _ = tx.send(agents);
907                                            }
908                                        }
909                                        config.store(Arc::new(new_config));
910                                        info!("Config reloaded successfully");
911                                    }
912                                    Err(e) => {
913                                        error!(
914                                            "Config validation failed, keeping old config: {}",
915                                            e
916                                        );
917                                    }
918                                }
919                            }
920                            Err(e) => {
921                                error!("Failed to reload config: {}", e);
922                            }
923                        }
924                    }
925                }
926                Err(e) => {
927                    error!("Watch error: {:?}", e);
928                }
929            }
930        })?;
931
932        // Watch all config directories
933        for dir in [
934            &agents_dir,
935            &models_dir,
936            &tools_dir,
937            &workflows_dir,
938            &mcps_dir,
939        ] {
940            if dir.exists() {
941                watcher.watch(dir, RecursiveMode::Recursive)?;
942                debug!("Watching directory: {:?}", dir);
943            }
944        }
945
946        Ok(watcher)
947    }
948
949    /// Get current config snapshot (lock-free)
950    pub fn config(&self) -> arc_swap::Guard<Arc<DynamicConfig>> {
951        self.config.load()
952    }
953
954    /// Get a specific agent config
955    pub fn agent(&self, name: &str) -> Option<ToonAgentConfig> {
956        self.config.load().get_agent(name).cloned()
957    }
958
959    /// Get a specific model config
960    pub fn model(&self, name: &str) -> Option<ToonModelConfig> {
961        self.config.load().get_model(name).cloned()
962    }
963
964    /// Get a specific tool config
965    pub fn tool(&self, name: &str) -> Option<ToonToolConfig> {
966        self.config.load().get_tool(name).cloned()
967    }
968
969    /// Get a specific workflow config
970    pub fn workflow(&self, name: &str) -> Option<ToonWorkflowConfig> {
971        self.config.load().get_workflow(name).cloned()
972    }
973
974    /// Get a specific MCP config
975    pub fn mcp(&self, name: &str) -> Option<ToonMcpConfig> {
976        self.config.load().get_mcp(name).cloned()
977    }
978
979    /// Get all agents
980    pub fn agents(&self) -> Vec<ToonAgentConfig> {
981        self.config.load().agents.values().cloned().collect()
982    }
983
984    /// Get all models
985    pub fn models(&self) -> Vec<ToonModelConfig> {
986        self.config.load().models.values().cloned().collect()
987    }
988
989    /// Get all tools
990    pub fn tools(&self) -> Vec<ToonToolConfig> {
991        self.config.load().tools.values().cloned().collect()
992    }
993
994    /// Get all workflows
995    pub fn workflows(&self) -> Vec<ToonWorkflowConfig> {
996        self.config.load().workflows.values().cloned().collect()
997    }
998
999    /// Get all MCPs
1000    pub fn mcps(&self) -> Vec<ToonMcpConfig> {
1001        self.config.load().mcps.values().cloned().collect()
1002    }
1003
1004    /// Get all agent names
1005    pub fn agent_names(&self) -> Vec<String> {
1006        self.config
1007            .load()
1008            .agent_names()
1009            .into_iter()
1010            .map(String::from)
1011            .collect()
1012    }
1013
1014    /// Get all model names
1015    pub fn model_names(&self) -> Vec<String> {
1016        self.config
1017            .load()
1018            .model_names()
1019            .into_iter()
1020            .map(String::from)
1021            .collect()
1022    }
1023
1024    /// Get all tool names
1025    pub fn tool_names(&self) -> Vec<String> {
1026        self.config
1027            .load()
1028            .tool_names()
1029            .into_iter()
1030            .map(String::from)
1031            .collect()
1032    }
1033
1034    /// Get all workflow names
1035    pub fn workflow_names(&self) -> Vec<String> {
1036        self.config
1037            .load()
1038            .workflow_names()
1039            .into_iter()
1040            .map(String::from)
1041            .collect()
1042    }
1043
1044    /// Get all MCP names
1045    pub fn mcp_names(&self) -> Vec<String> {
1046        self.config
1047            .load()
1048            .mcp_names()
1049            .into_iter()
1050            .map(String::from)
1051            .collect()
1052    }
1053
1054    /// Hot-swap a single agent config in the in-memory cache (used for rollback).
1055    /// Does not write to disk — disk files are the canonical source on next restart.
1056    pub fn upsert_agent(&self, agent: ToonAgentConfig) {
1057        let current = self.config.load();
1058        let mut new_agents = current.agents.clone();
1059        new_agents.insert(agent.name.clone(), agent);
1060        let new_config = DynamicConfig {
1061            agents: new_agents,
1062            models: current.models.clone(),
1063            tools: current.tools.clone(),
1064            workflows: current.workflows.clone(),
1065            mcps: current.mcps.clone(),
1066        };
1067        self.config.store(Arc::new(new_config));
1068    }
1069
1070    /// Manually reload configuration
1071    pub fn reload(&self) -> Result<Vec<ConfigWarning>, ToonConfigError> {
1072        let new_config = DynamicConfig::load(
1073            &self.agents_dir,
1074            &self.models_dir,
1075            &self.tools_dir,
1076            &self.workflows_dir,
1077            &self.mcps_dir,
1078        )?;
1079
1080        let warnings = new_config.validate()?;
1081        self.config.store(Arc::new(new_config));
1082        Ok(warnings)
1083    }
1084}
1085
1086// ============= Tests =============
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091    use tempfile::TempDir;
1092
1093    #[test]
1094    fn test_agent_config_roundtrip() {
1095        let agent = ToonAgentConfig::new("test-agent", "fast")
1096            .with_system_prompt("You are a test agent.")
1097            .with_tools(vec!["calculator".to_string(), "web_search".to_string()]);
1098
1099        let toon = agent.to_toon().expect("Failed to encode");
1100        let decoded = ToonAgentConfig::from_toon(&toon).expect("Failed to decode");
1101
1102        assert_eq!(agent.name, decoded.name);
1103        assert_eq!(agent.model, decoded.model);
1104        assert_eq!(agent.system_prompt, decoded.system_prompt);
1105        assert_eq!(agent.tools, decoded.tools);
1106    }
1107
1108    #[test]
1109    fn test_model_config_roundtrip() {
1110        let model = ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b");
1111
1112        let toon = model.to_toon().expect("Failed to encode");
1113        let decoded = ToonModelConfig::from_toon(&toon).expect("Failed to decode");
1114
1115        assert_eq!(model.name, decoded.name);
1116        assert_eq!(model.provider, decoded.provider);
1117        assert_eq!(model.model, decoded.model);
1118        assert_eq!(model.temperature, decoded.temperature);
1119        assert_eq!(model.max_tokens, decoded.max_tokens);
1120    }
1121
1122    #[test]
1123    fn test_tool_config_roundtrip() {
1124        let mut tool = ToonToolConfig::new("calculator");
1125        tool.description = Some("Performs arithmetic operations".to_string());
1126        tool.timeout_secs = 10;
1127
1128        let toon = tool.to_toon().expect("Failed to encode");
1129        let decoded = ToonToolConfig::from_toon(&toon).expect("Failed to decode");
1130
1131        assert_eq!(tool.name, decoded.name);
1132        assert_eq!(tool.enabled, decoded.enabled);
1133        assert_eq!(tool.description, decoded.description);
1134        assert_eq!(tool.timeout_secs, decoded.timeout_secs);
1135    }
1136
1137    #[test]
1138    fn test_workflow_config_roundtrip() {
1139        let mut workflow = ToonWorkflowConfig::new("default", "router");
1140        workflow.fallback_agent = Some("orchestrator".to_string());
1141        workflow.max_depth = 3;
1142        workflow.max_iterations = 5;
1143
1144        let toon = workflow.to_toon().expect("Failed to encode");
1145        let decoded = ToonWorkflowConfig::from_toon(&toon).expect("Failed to decode");
1146
1147        assert_eq!(workflow.name, decoded.name);
1148        assert_eq!(workflow.entry_agent, decoded.entry_agent);
1149        assert_eq!(workflow.fallback_agent, decoded.fallback_agent);
1150        assert_eq!(workflow.max_depth, decoded.max_depth);
1151        assert_eq!(workflow.max_iterations, decoded.max_iterations);
1152    }
1153
1154    #[test]
1155    fn test_mcp_config_roundtrip() {
1156        let mut mcp = ToonMcpConfig::new("filesystem", "npx");
1157        mcp.args = vec![
1158            "-y".to_string(),
1159            "@modelcontextprotocol/server-filesystem".to_string(),
1160            "/home".to_string(),
1161            "/tmp".to_string(),
1162        ];
1163        mcp.env
1164            .insert("NODE_ENV".to_string(), "production".to_string());
1165        mcp.timeout_secs = 30;
1166
1167        let toon = mcp.to_toon().expect("Failed to encode");
1168        let decoded = ToonMcpConfig::from_toon(&toon).expect("Failed to decode");
1169
1170        assert_eq!(mcp.name, decoded.name);
1171        assert_eq!(mcp.command, decoded.command);
1172        assert_eq!(mcp.args, decoded.args);
1173        assert_eq!(mcp.env, decoded.env);
1174        assert_eq!(mcp.timeout_secs, decoded.timeout_secs);
1175    }
1176
1177    #[test]
1178    fn test_load_configs_from_dir() {
1179        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1180        let agents_dir = temp_dir.path().join("agents");
1181        fs::create_dir_all(&agents_dir).expect("Failed to create agents dir");
1182
1183        // Create a test agent TOON file
1184        let agent_content = r#"name: test-agent
1185model: fast
1186max_tool_iterations: 5
1187parallel_tools: false
1188tools[0]:
1189system_prompt: Test agent prompt"#;
1190
1191        fs::write(agents_dir.join("test-agent.toon"), agent_content)
1192            .expect("Failed to write agent file");
1193
1194        let agents = load_configs_from_dir::<ToonAgentConfig>(&agents_dir, "agents")
1195            .expect("Failed to load agents");
1196
1197        assert_eq!(agents.len(), 1);
1198        let agent = agents.get("test-agent").expect("Agent not found");
1199        assert_eq!(agent.name, "test-agent");
1200        assert_eq!(agent.model, "fast");
1201        assert_eq!(agent.max_tool_iterations, 5);
1202    }
1203
1204    #[test]
1205    fn test_dynamic_config_validation() {
1206        let mut config = DynamicConfig::default();
1207
1208        // Add a model
1209        config.models.insert(
1210            "fast".to_string(),
1211            ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b"),
1212        );
1213
1214        // Add a tool
1215        config
1216            .tools
1217            .insert("calculator".to_string(), ToonToolConfig::new("calculator"));
1218
1219        // Add an agent that uses the model and tool
1220        let mut agent = ToonAgentConfig::new("router", "fast");
1221        agent.tools = vec!["calculator".to_string()];
1222        config.agents.insert("router".to_string(), agent);
1223
1224        // Add a workflow that uses the agent
1225        config.workflows.insert(
1226            "default".to_string(),
1227            ToonWorkflowConfig::new("default", "router"),
1228        );
1229
1230        // Validation should pass
1231        let warnings = config.validate().expect("Validation failed");
1232        assert!(warnings.is_empty());
1233    }
1234
1235    #[test]
1236    fn test_dynamic_config_validation_missing_model() {
1237        let mut config = DynamicConfig::default();
1238
1239        // Add an agent that references a non-existent model
1240        let agent = ToonAgentConfig::new("router", "non-existent-model");
1241        config.agents.insert("router".to_string(), agent);
1242
1243        // Validation should fail
1244        let result = config.validate();
1245        assert!(result.is_err());
1246        assert!(result.unwrap_err().to_string().contains("unknown model"));
1247    }
1248
1249    #[test]
1250    fn test_dynamic_config_validation_missing_tool() {
1251        let mut config = DynamicConfig::default();
1252
1253        // Add a model
1254        config.models.insert(
1255            "fast".to_string(),
1256            ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b"),
1257        );
1258
1259        // Add an agent that references a non-existent tool
1260        let mut agent = ToonAgentConfig::new("router", "fast");
1261        agent.tools = vec!["non-existent-tool".to_string()];
1262        config.agents.insert("router".to_string(), agent);
1263
1264        // Validation should fail
1265        let result = config.validate();
1266        assert!(result.is_err());
1267        assert!(result.unwrap_err().to_string().contains("unknown tool"));
1268    }
1269
1270    #[test]
1271    fn test_parse_agent_from_toon_string() {
1272        let toon = r#"name: router
1273model: fast
1274max_tool_iterations: 1
1275parallel_tools: false
1276tools[0]:
1277system_prompt: You are a routing agent."#;
1278
1279        let agent = ToonAgentConfig::from_toon(toon).expect("Failed to parse");
1280        assert_eq!(agent.name, "router");
1281        assert_eq!(agent.model, "fast");
1282        assert_eq!(agent.max_tool_iterations, 1);
1283        assert!(!agent.parallel_tools);
1284        assert!(agent.tools.is_empty());
1285    }
1286
1287    #[test]
1288    fn test_parse_model_from_toon_string() {
1289        let toon = r#"name: fast
1290provider: ollama-local
1291model: ministral-3:3b
1292temperature: 0.7
1293max_tokens: 256"#;
1294
1295        let model = ToonModelConfig::from_toon(toon).expect("Failed to parse");
1296        assert_eq!(model.name, "fast");
1297        assert_eq!(model.provider, "ollama-local");
1298        assert_eq!(model.model, "ministral-3:3b");
1299        assert!((model.temperature - 0.7).abs() < 0.01);
1300        assert_eq!(model.max_tokens, 256);
1301    }
1302}