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