Skip to main content

ares_http/
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;
35#[cfg(unix)]
36type ConfigFsNotify = notify::INotifyWatcher;
37#[cfg(windows)]
38type ConfigFsNotify = notify::ReadDirectoryChangesWatcher;
39#[cfg(not(any(unix, windows)))]
40type ConfigFsNotify = notify::PollWatcher;
41use notify::{Event, RecursiveMode, Watcher};
42use serde::{Deserialize, Serialize};
43use std::any::TypeId;
44use std::collections::HashMap;
45use std::fs;
46use std::path::{Path, PathBuf};
47use std::sync::Arc;
48use toon_format::{decode_default, encode_default, ToonError};
49use tracing::{debug, error, info, warn};
50
51// ============= Agent Configuration =============
52
53/// Configuration for an AI agent loaded from TOON files
54///
55/// Agents are the core behavioral units in ARES. Each agent has:
56/// - A model reference (defined in `config/models/*.toon`)
57/// - A system prompt defining its behavior
58/// - Optional tools it can use
59/// - Iteration limits for tool calling
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct ToonAgentConfig {
62    /// Unique identifier for the agent
63    pub name: String,
64
65    /// Semantic version of this config (e.g. "1.0.0")
66    /// Increment on any behavior-changing edit. Stored in agent_config_versions on every change.
67    #[serde(default = "default_version")]
68    pub version: String,
69
70    /// Reference to a model name defined in `config/models/`
71    pub model: String,
72
73    /// System prompt defining agent behavior
74    #[serde(default)]
75    pub system_prompt: Option<String>,
76
77    /// List of tool names this agent can use (defined in `config/tools/`)
78    #[serde(default)]
79    pub tools: Vec<String>,
80
81    /// Optional whitelist of tool names this agent is allowed to use.
82    /// If absent, all tools are permitted.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub allowed_tools: Option<Vec<String>>,
85
86    /// Maximum tool calling iterations before returning
87    #[serde(default = "default_max_tool_iterations")]
88    pub max_tool_iterations: usize,
89
90    /// Whether to execute multiple tool calls in parallel
91    #[serde(default)]
92    pub parallel_tools: bool,
93
94    /// Additional agent-specific configuration (extensible)
95    #[serde(flatten)]
96    pub extra: HashMap<String, serde_json::Value>,
97}
98
99fn default_version() -> String {
100    "0.1.0".to_string()
101}
102
103fn default_max_tool_iterations() -> usize {
104    10
105}
106
107impl ToonAgentConfig {
108    /// Create a new agent config with required fields
109    pub fn new(name: impl Into<String>, model: impl Into<String>) -> Self {
110        Self {
111            name: name.into(),
112            version: default_version(),
113            model: model.into(),
114            system_prompt: None,
115            tools: Vec::new(),
116            allowed_tools: None,
117            max_tool_iterations: default_max_tool_iterations(),
118            parallel_tools: false,
119            extra: HashMap::new(),
120        }
121    }
122
123    /// Set the system prompt
124    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
125        self.system_prompt = Some(prompt.into());
126        self
127    }
128
129    /// Set the tools list
130    pub fn with_tools(mut self, tools: Vec<String>) -> Self {
131        self.tools = tools;
132        self
133    }
134
135    /// Encode this config to TOON format
136    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
137        encode_default(self).map_err(ToonConfigError::from)
138    }
139
140    /// Parse an agent config from TOON format
141    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
142        decode_default(toon).map_err(ToonConfigError::from)
143    }
144}
145
146// ============= Model Configuration =============
147
148/// Configuration for an LLM model loaded from TOON files
149///
150/// Models reference providers defined in `ares.toml` and specify
151/// inference parameters like temperature and token limits.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153pub struct ToonModelConfig {
154    /// Unique identifier for the model configuration
155    pub name: String,
156
157    /// Reference to a provider name defined in `ares.toml` [providers.*]
158    pub provider: String,
159
160    /// Model name/identifier to use with the provider (e.g., "gpt-4", "ministral-3:3b")
161    pub model: String,
162
163    /// Sampling temperature (0.0 = deterministic, 1.0+ = creative)
164    #[serde(default = "default_temperature")]
165    pub temperature: f32,
166
167    /// Maximum tokens to generate
168    #[serde(default = "default_max_tokens")]
169    pub max_tokens: u32,
170
171    /// Optional nucleus sampling parameter
172    #[serde(default)]
173    pub top_p: Option<f32>,
174
175    /// Optional frequency penalty (-2.0 to 2.0)
176    #[serde(default)]
177    pub frequency_penalty: Option<f32>,
178
179    /// Optional presence penalty (-2.0 to 2.0)
180    #[serde(default)]
181    pub presence_penalty: Option<f32>,
182}
183
184fn default_temperature() -> f32 {
185    0.7
186}
187
188fn default_max_tokens() -> u32 {
189    512
190}
191
192impl ToonModelConfig {
193    /// Create a new model config with required fields
194    pub fn new(
195        name: impl Into<String>,
196        provider: impl Into<String>,
197        model: impl Into<String>,
198    ) -> Self {
199        Self {
200            name: name.into(),
201            provider: provider.into(),
202            model: model.into(),
203            temperature: default_temperature(),
204            max_tokens: default_max_tokens(),
205            top_p: None,
206            frequency_penalty: None,
207            presence_penalty: None,
208        }
209    }
210
211    /// Encode this config to TOON format
212    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
213        encode_default(self).map_err(ToonConfigError::from)
214    }
215
216    /// Parse a model config from TOON format
217    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
218        decode_default(toon).map_err(ToonConfigError::from)
219    }
220}
221
222// ============= Tool Configuration =============
223
224/// Configuration for a tool loaded from TOON files
225///
226/// Tools provide external capabilities to agents (calculator, web search, etc.)
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228pub struct ToonToolConfig {
229    /// Unique identifier for the tool
230    pub name: String,
231
232    /// Whether this tool is currently enabled
233    #[serde(default = "default_true")]
234    pub enabled: bool,
235
236    /// Human-readable description of what the tool does
237    #[serde(default)]
238    pub description: Option<String>,
239
240    /// Timeout in seconds for tool execution
241    #[serde(default = "default_timeout")]
242    pub timeout_secs: u64,
243
244    /// Additional tool-specific configuration
245    #[serde(flatten)]
246    pub extra: HashMap<String, serde_json::Value>,
247}
248
249fn default_true() -> bool {
250    true
251}
252
253fn default_timeout() -> u64 {
254    30
255}
256
257impl ToonToolConfig {
258    /// Create a new tool config with required fields
259    pub fn new(name: impl Into<String>) -> Self {
260        Self {
261            name: name.into(),
262            enabled: default_true(),
263            description: None,
264            timeout_secs: default_timeout(),
265            extra: HashMap::new(),
266        }
267    }
268
269    /// Encode this config to TOON format
270    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
271        encode_default(self).map_err(ToonConfigError::from)
272    }
273
274    /// Parse a tool config from TOON format
275    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
276        decode_default(toon).map_err(ToonConfigError::from)
277    }
278}
279
280// ============= Workflow Configuration =============
281
282/// Configuration for a workflow loaded from TOON files
283///
284/// Workflows define how agents work together to handle complex requests.
285/// They specify entry points, fallbacks, and iteration limits.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct ToonWorkflowConfig {
288    /// Unique identifier for the workflow
289    pub name: String,
290
291    /// The agent that first receives requests
292    pub entry_agent: String,
293
294    /// Agent to use if routing/entry fails
295    #[serde(default)]
296    pub fallback_agent: Option<String>,
297
298    /// Maximum depth for recursive agent calls
299    #[serde(default = "default_max_depth")]
300    pub max_depth: u8,
301
302    /// Maximum total iterations across all agents
303    #[serde(default = "default_max_iterations")]
304    pub max_iterations: u8,
305
306    /// Whether to run subagents in parallel when possible
307    #[serde(default)]
308    pub parallel_subagents: bool,
309}
310
311fn default_max_depth() -> u8 {
312    3
313}
314
315fn default_max_iterations() -> u8 {
316    5
317}
318
319impl ToonWorkflowConfig {
320    /// Create a new workflow config with required fields
321    pub fn new(name: impl Into<String>, entry_agent: impl Into<String>) -> Self {
322        Self {
323            name: name.into(),
324            entry_agent: entry_agent.into(),
325            fallback_agent: None,
326            max_depth: default_max_depth(),
327            max_iterations: default_max_iterations(),
328            parallel_subagents: false,
329        }
330    }
331
332    /// Encode this config to TOON format
333    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
334        encode_default(self).map_err(ToonConfigError::from)
335    }
336
337    /// Parse a workflow config from TOON format
338    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
339        decode_default(toon).map_err(ToonConfigError::from)
340    }
341}
342
343// ============= MCP Server Configuration =============
344
345/// Configuration for an MCP (Model Context Protocol) server
346///
347/// MCP servers provide additional capabilities to agents via a standardized protocol.
348/// See: <https://modelcontextprotocol.io/>
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350pub struct ToonMcpConfig {
351    /// Unique identifier for the MCP server
352    pub name: String,
353
354    /// Whether this MCP server is currently enabled
355    #[serde(default = "default_true")]
356    pub enabled: bool,
357
358    /// Command to run the MCP server (e.g., "npx", "python"). Optional for HTTP transport.
359    #[serde(default)]
360    pub command: Option<String>,
361
362    /// Arguments to pass to the command
363    #[serde(default)]
364    pub args: Vec<String>,
365
366    /// Environment variables to set for the MCP server
367    #[serde(default)]
368    pub env: HashMap<String, String>,
369
370    /// Timeout in seconds for MCP operations
371    #[serde(default = "default_timeout")]
372    pub timeout_secs: u64,
373}
374
375impl ToonMcpConfig {
376    /// Create a new MCP config with required fields
377    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
378        Self {
379            name: name.into(),
380            enabled: default_true(),
381            command: Some(command.into()),
382            args: Vec::new(),
383            env: HashMap::new(),
384            timeout_secs: default_timeout(),
385        }
386    }
387
388    /// Encode this config to TOON format
389    pub fn to_toon(&self) -> Result<String, ToonConfigError> {
390        encode_default(self).map_err(ToonConfigError::from)
391    }
392
393    /// Parse an MCP config from TOON format
394    pub fn from_toon(toon: &str) -> Result<Self, ToonConfigError> {
395        decode_default(toon).map_err(ToonConfigError::from)
396    }
397}
398
399// ============= Dynamic Config Aggregate =============
400
401/// Aggregated dynamic configuration from all TOON files
402///
403/// This struct holds all behavioral configuration loaded from the
404/// `config/` directory tree. It is wrapped in `ArcSwap` for
405/// lock-free concurrent access with atomic updates during hot-reload.
406#[derive(Debug, Clone, Default)]
407pub struct DynamicConfig {
408    /// Agent configurations keyed by name
409    pub agents: HashMap<String, ToonAgentConfig>,
410    /// Model configurations keyed by name
411    pub models: HashMap<String, ToonModelConfig>,
412    /// Tool configurations keyed by name
413    pub tools: HashMap<String, ToonToolConfig>,
414    /// Workflow configurations keyed by name
415    pub workflows: HashMap<String, ToonWorkflowConfig>,
416    /// MCP server configurations keyed by name
417    pub mcps: HashMap<String, ToonMcpConfig>,
418}
419
420impl DynamicConfig {
421    /// Load all TOON configs from directories
422    pub fn load(
423        agents_dir: &Path,
424        models_dir: &Path,
425        tools_dir: &Path,
426        workflows_dir: &Path,
427        mcps_dir: &Path,
428    ) -> Result<Self, ToonConfigError> {
429        let agents = load_configs_from_dir::<ToonAgentConfig>(agents_dir, "agents")?;
430        let models = load_configs_from_dir::<ToonModelConfig>(models_dir, "models")?;
431        let tools = load_configs_from_dir::<ToonToolConfig>(tools_dir, "tools")?;
432        let workflows = load_configs_from_dir::<ToonWorkflowConfig>(workflows_dir, "workflows")?;
433        let mcps = load_configs_from_dir::<ToonMcpConfig>(mcps_dir, "mcps")?;
434
435        info!(
436            "Loaded dynamic config: {} agents, {} models, {} tools, {} workflows, {} mcps",
437            agents.len(),
438            models.len(),
439            tools.len(),
440            workflows.len(),
441            mcps.len()
442        );
443
444        Ok(Self {
445            agents,
446            models,
447            tools,
448            workflows,
449            mcps,
450        })
451    }
452
453    /// Get an agent config by name
454    pub fn get_agent(&self, name: &str) -> Option<&ToonAgentConfig> {
455        self.agents.get(name)
456    }
457
458    /// Get a model config by name
459    pub fn get_model(&self, name: &str) -> Option<&ToonModelConfig> {
460        self.models.get(name)
461    }
462
463    /// Get a tool config by name
464    pub fn get_tool(&self, name: &str) -> Option<&ToonToolConfig> {
465        self.tools.get(name)
466    }
467
468    /// Get a workflow config by name
469    pub fn get_workflow(&self, name: &str) -> Option<&ToonWorkflowConfig> {
470        self.workflows.get(name)
471    }
472
473    /// Get an MCP config by name
474    pub fn get_mcp(&self, name: &str) -> Option<&ToonMcpConfig> {
475        self.mcps.get(name)
476    }
477
478    /// Get all agent names
479    pub fn agent_names(&self) -> Vec<&str> {
480        self.agents.keys().map(|s| s.as_str()).collect()
481    }
482
483    /// Get all model names
484    pub fn model_names(&self) -> Vec<&str> {
485        self.models.keys().map(|s| s.as_str()).collect()
486    }
487
488    /// Get all tool names
489    pub fn tool_names(&self) -> Vec<&str> {
490        self.tools.keys().map(|s| s.as_str()).collect()
491    }
492
493    /// Get all workflow names
494    pub fn workflow_names(&self) -> Vec<&str> {
495        self.workflows.keys().map(|s| s.as_str()).collect()
496    }
497
498    /// Get all MCP names
499    pub fn mcp_names(&self) -> Vec<&str> {
500        self.mcps.keys().map(|s| s.as_str()).collect()
501    }
502
503    /// Validate the configuration for internal consistency
504    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ToonConfigError> {
505        let mut warnings = Vec::new();
506
507        // Validate agent -> model references
508        for (agent_name, agent) in &self.agents {
509            if !self.models.contains_key(&agent.model) {
510                return Err(ToonConfigError::Validation(format!(
511                    "Agent '{}' references unknown model '{}'",
512                    agent_name, agent.model
513                )));
514            }
515
516            // Validate agent -> tools references
517            for tool_name in &agent.tools {
518                if !self.tools.contains_key(tool_name) {
519                    return Err(ToonConfigError::Validation(format!(
520                        "Agent '{}' references unknown tool '{}'",
521                        agent_name, tool_name
522                    )));
523                }
524            }
525        }
526
527        // Validate workflow -> agent references
528        for (workflow_name, workflow) in &self.workflows {
529            if !self.agents.contains_key(&workflow.entry_agent) {
530                return Err(ToonConfigError::Validation(format!(
531                    "Workflow '{}' references unknown entry agent '{}'",
532                    workflow_name, workflow.entry_agent
533                )));
534            }
535
536            if let Some(ref fallback) = workflow.fallback_agent {
537                if !self.agents.contains_key(fallback) {
538                    return Err(ToonConfigError::Validation(format!(
539                        "Workflow '{}' references unknown fallback agent '{}'",
540                        workflow_name, fallback
541                    )));
542                }
543            }
544        }
545
546        // Check for unused models
547        let used_models: std::collections::HashSet<_> =
548            self.agents.values().map(|a| &a.model).collect();
549        for model_name in self.models.keys() {
550            if !used_models.contains(model_name) {
551                warnings.push(ConfigWarning {
552                    kind: WarningKind::UnusedModel,
553                    message: format!("Model '{}' is not used by any agent", model_name),
554                });
555            }
556        }
557
558        // Check for unused tools
559        let used_tools: std::collections::HashSet<_> =
560            self.agents.values().flat_map(|a| a.tools.iter()).collect();
561        for tool_name in self.tools.keys() {
562            if !used_tools.contains(tool_name) {
563                warnings.push(ConfigWarning {
564                    kind: WarningKind::UnusedTool,
565                    message: format!("Tool '{}' is not used by any agent", tool_name),
566                });
567            }
568        }
569
570        Ok(warnings)
571    }
572}
573
574// ============= Config Loading Helpers =============
575
576/// Trait for config types that have a name field.
577///
578/// All TOON config types must implement this trait to enable
579/// automatic keying by name when loading from directories.
580pub trait HasName {
581    /// Returns the unique name/identifier of this configuration.
582    fn name(&self) -> &str;
583}
584
585impl HasName for ToonAgentConfig {
586    fn name(&self) -> &str {
587        &self.name
588    }
589}
590
591impl HasName for ToonModelConfig {
592    fn name(&self) -> &str {
593        &self.name
594    }
595}
596
597impl HasName for ToonToolConfig {
598    fn name(&self) -> &str {
599        &self.name
600    }
601}
602
603impl HasName for ToonWorkflowConfig {
604    fn name(&self) -> &str {
605        &self.name
606    }
607}
608
609impl HasName for ToonMcpConfig {
610    fn name(&self) -> &str {
611        &self.name
612    }
613}
614
615/// Load all .toon files from a directory into a HashMap keyed by name
616fn load_configs_from_dir<T>(
617    dir: &Path,
618    config_type: &str,
619) -> Result<HashMap<String, T>, ToonConfigError>
620where
621    T: for<'de> Deserialize<'de> + HasName,
622{
623    let mut configs = HashMap::new();
624
625    if !dir.exists() {
626        debug!("Config directory does not exist: {:?}", dir);
627        return Ok(configs);
628    }
629
630    let entries = fs::read_dir(dir).map_err(|e| {
631        ToonConfigError::Io(std::io::Error::new(
632            e.kind(),
633            format!("Failed to read {} directory {:?}: {}", config_type, dir, e),
634        ))
635    })?;
636
637    for entry in entries {
638        let entry = entry.map_err(ToonConfigError::Io)?;
639        let path = entry.path();
640
641        // Only process .toon files
642        if path.extension().and_then(|e| e.to_str()) != Some("toon") {
643            continue;
644        }
645
646        match load_toon_file::<T>(&path) {
647            Ok(config) => {
648                let name = config.name().to_string();
649                debug!("Loaded {} config: {}", config_type, name);
650                configs.insert(name, config);
651            }
652            Err(e) => {
653                warn!("Failed to load {} from {:?}: {}", config_type, path, e);
654            }
655        }
656    }
657
658    Ok(configs)
659}
660
661/// Load a single TOON file and deserialize it
662fn load_toon_file<T>(path: &Path) -> Result<T, ToonConfigError>
663where
664    T: for<'de> Deserialize<'de>,
665{
666    let content = fs::read_to_string(path).map_err(|e| {
667        ToonConfigError::Io(std::io::Error::new(
668            e.kind(),
669            format!("Failed to read {:?}: {}", path, e),
670        ))
671    })?;
672
673    match decode_default(&content) {
674        Ok(config) => Ok(config),
675        Err(toon_error) => toml::from_str(&content).map_err(|toml_error| {
676            ToonConfigError::Parse(format!(
677                "Failed to parse {:?} as TOON ({}) or TOML ({})",
678                path, toon_error, toml_error
679            ))
680        }),
681    }
682}
683
684// ============= Error Types =============
685
686/// Errors that can occur during TOON configuration loading.
687#[derive(Debug, thiserror::Error)]
688pub enum ToonConfigError {
689    /// An I/O error occurred while reading configuration files.
690    #[error("IO error: {0}")]
691    Io(#[from] std::io::Error),
692
693    /// Failed to parse TOON format content.
694    #[error("TOON parse error: {0}")]
695    Parse(String),
696
697    /// Configuration validation failed (e.g., missing references).
698    #[error("Validation error: {0}")]
699    Validation(String),
700
701    /// An error occurred while watching configuration files for changes.
702    #[error("Watch error: {0}")]
703    Watch(#[from] notify::Error),
704}
705
706impl From<ToonError> for ToonConfigError {
707    fn from(e: ToonError) -> Self {
708        ToonConfigError::Parse(e.to_string())
709    }
710}
711
712/// Non-fatal configuration warnings.
713#[derive(Debug, Clone)]
714pub struct ConfigWarning {
715    /// Category of the warning.
716    pub kind: WarningKind,
717
718    /// Human-readable warning message.
719    pub message: String,
720}
721
722/// Categories of TOON configuration warnings.
723#[derive(Debug, Clone, PartialEq)]
724pub enum WarningKind {
725    /// A model is defined but not referenced by any agent.
726    UnusedModel,
727
728    /// A tool is defined but not referenced by any agent.
729    UnusedTool,
730
731    /// An agent is defined but not used in any workflow.
732    UnusedAgent,
733
734    /// A workflow is defined but not the default or referenced.
735    UnusedWorkflow,
736
737    /// An MCP server is defined but not referenced.
738    UnusedMcp,
739}
740
741impl std::fmt::Display for ConfigWarning {
742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743        write!(f, "{}", self.message)
744    }
745}
746
747// ============= Hot Reload Manager =============
748
749/// Manager for dynamic TOON configuration with hot-reload support
750///
751/// This manager:
752/// - Loads all TOON configs at startup
753/// - Watches config directories for changes
754/// - Atomically swaps config on changes (lock-free reads)
755/// - Provides convenient accessor methods
756///
757/// # Example
758///
759/// ```rust,ignore
760/// let manager = DynamicConfigManager::new(
761///     PathBuf::from("config/agents"),
762///     PathBuf::from("config/models"),
763///     PathBuf::from("config/tools"),
764///     PathBuf::from("config/workflows"),
765///     PathBuf::from("config/mcps"),
766///     true, // hot_reload
767/// )?;
768///
769/// // Get an agent config (lock-free)
770/// if let Some(router) = manager.agent("router") {
771///     println!("Router uses model: {}", router.model);
772/// }
773/// ```
774pub struct DynamicConfigManager {
775    config: Arc<ArcSwap<DynamicConfig>>,
776    agents_dir: PathBuf,
777    models_dir: PathBuf,
778    tools_dir: PathBuf,
779    workflows_dir: PathBuf,
780    mcps_dir: PathBuf,
781    _watcher: Option<ConfigFsNotify>,
782    /// Shared sender for version change events. Populated via `set_version_tx`.
783    /// The watcher closure holds a clone of this Arc, so setting it after construction
784    /// is visible inside the watcher.
785    version_tx:
786        Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>>,
787}
788
789impl DynamicConfigManager {
790    /// Create DynamicConfigManager from AresConfig
791    ///
792    /// This uses the paths defined in `config.config` (DynamicConfigPaths)
793    /// to initialize the manager.
794    pub fn from_config(
795        config: &crate::overlay::AresConfig,
796    ) -> Result<Self, ToonConfigError> {
797        let agents_dir = PathBuf::from(&config.config.agents_dir);
798        let models_dir = PathBuf::from(&config.config.models_dir);
799        let tools_dir = PathBuf::from(&config.config.tools_dir);
800        let workflows_dir = PathBuf::from(&config.config.workflows_dir);
801        let mcps_dir = PathBuf::from(&config.config.mcps_dir);
802
803        Self::new(
804            agents_dir,
805            models_dir,
806            tools_dir,
807            workflows_dir,
808            mcps_dir,
809            false, // Overlay owns the single watch_many_with; no second notify watcher
810        )
811    }
812
813    /// Reload TOON files from the configured directories (Overlay watch callback).
814    pub fn reload(&self) -> Result<Vec<ConfigWarning>, ToonConfigError> {
815        let new_config = DynamicConfig::load(
816            &self.agents_dir,
817            &self.models_dir,
818            &self.tools_dir,
819            &self.workflows_dir,
820            &self.mcps_dir,
821        )?;
822        match new_config.validate() {
823            Ok(warnings) => {
824                for warning in &warnings {
825                    warn!("Config warning: {}", warning);
826                }
827                let agents: Vec<ToonAgentConfig> = new_config.agents.values().cloned().collect();
828                if let Ok(guard) = self.version_tx.lock() {
829                    if let Some(tx) = guard.as_ref() {
830                        let _ = tx.send(agents);
831                    }
832                }
833                self.config.store(Arc::new(new_config));
834                info!("Config reloaded successfully");
835                Ok(warnings)
836            }
837            Err(e) => {
838                error!("Config validation failed, keeping old config: {}", e);
839                Err(e)
840            }
841        }
842    }
843
844    /// Create a new DynamicConfigManager
845    ///
846    /// # Arguments
847    /// * `agents_dir` - Directory containing agent TOON files
848    /// * `models_dir` - Directory containing model TOON files
849    /// * `tools_dir` - Directory containing tool TOON files
850    /// * `workflows_dir` - Directory containing workflow TOON files
851    /// * `mcps_dir` - Directory containing MCP TOON files
852    /// * `hot_reload` - Whether to watch for file changes
853    pub fn new(
854        agents_dir: PathBuf,
855        models_dir: PathBuf,
856        tools_dir: PathBuf,
857        workflows_dir: PathBuf,
858        mcps_dir: PathBuf,
859        hot_reload: bool,
860    ) -> Result<Self, ToonConfigError> {
861        // Load initial config
862        let initial_config = DynamicConfig::load(
863            &agents_dir,
864            &models_dir,
865            &tools_dir,
866            &workflows_dir,
867            &mcps_dir,
868        )?;
869
870        let config = Arc::new(ArcSwap::from_pointee(initial_config));
871
872        // Shared version channel — populated after construction via set_version_tx
873        let version_tx: Arc<
874            std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>,
875        > = Arc::new(std::sync::Mutex::new(None));
876
877        // Overlay::watch_cordis owns the single watch_many_with stack.
878        // Do not start a second notify watcher from DynamicConfigManager.
879        let _ = hot_reload;
880        let watcher = None;
881
882        Ok(Self {
883            config,
884            agents_dir,
885            models_dir,
886            tools_dir,
887            workflows_dir,
888            mcps_dir,
889            _watcher: watcher,
890            version_tx,
891        })
892    }
893
894    /// Attach a version tracking sender. After this call, every hot-reload emits the
895    /// updated agent list to this channel for a background task to persist to DB.
896    pub fn set_version_tx(&self, tx: tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>) {
897        if let Ok(mut guard) = self.version_tx.lock() {
898            *guard = Some(tx);
899        }
900    }
901
902    /// Set up file watcher for hot-reload (unused: Overlay owns watch_many_with).
903    #[allow(dead_code)]
904    fn setup_watcher(
905        config: Arc<ArcSwap<DynamicConfig>>,
906        agents_dir: PathBuf,
907        models_dir: PathBuf,
908        tools_dir: PathBuf,
909        workflows_dir: PathBuf,
910        mcps_dir: PathBuf,
911        version_tx: Arc<
912            std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<Vec<ToonAgentConfig>>>>,
913        >,
914    ) -> Result<ConfigFsNotify, ToonConfigError> {
915        let agents_dir_clone = agents_dir.clone();
916        let models_dir_clone = models_dir.clone();
917        let tools_dir_clone = tools_dir.clone();
918        let workflows_dir_clone = workflows_dir.clone();
919        let mcps_dir_clone = mcps_dir.clone();
920
921        let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
922            match res {
923                Ok(event) => {
924                    // Only reload on create, modify, or remove events
925                    if matches!(
926                        event.kind,
927                        notify::EventKind::Create(_)
928                            | notify::EventKind::Modify(_)
929                            | notify::EventKind::Remove(_)
930                    ) {
931                        info!("Config change detected, reloading...");
932
933                        match DynamicConfig::load(
934                            &agents_dir_clone,
935                            &models_dir_clone,
936                            &tools_dir_clone,
937                            &workflows_dir_clone,
938                            &mcps_dir_clone,
939                        ) {
940                            Ok(new_config) => {
941                                // Validate before swapping
942                                match new_config.validate() {
943                                    Ok(warnings) => {
944                                        for warning in warnings {
945                                            warn!("Config warning: {}", warning);
946                                        }
947                                        // Emit version change event before swapping
948                                        let agents: Vec<ToonAgentConfig> =
949                                            new_config.agents.values().cloned().collect();
950                                        if let Ok(guard) = version_tx.lock() {
951                                            if let Some(tx) = guard.as_ref() {
952                                                let _ = tx.send(agents);
953                                            }
954                                        }
955                                        config.store(Arc::new(new_config));
956                                        info!("Config reloaded successfully");
957                                    }
958                                    Err(e) => {
959                                        error!(
960                                            "Config validation failed, keeping old config: {}",
961                                            e
962                                        );
963                                    }
964                                }
965                            }
966                            Err(e) => {
967                                error!("Failed to reload config: {}", e);
968                            }
969                        }
970                    }
971                }
972                Err(e) => {
973                    error!("Watch error: {:?}", e);
974                }
975            }
976        })?;
977
978        // Watch all config directories
979        for dir in [
980            &agents_dir,
981            &models_dir,
982            &tools_dir,
983            &workflows_dir,
984            &mcps_dir,
985        ] {
986            if dir.exists() {
987                watcher.watch(dir, RecursiveMode::Recursive)?;
988                debug!("Watching directory: {:?}", dir);
989            }
990        }
991
992        Ok(watcher)
993    }
994
995    /// Get current config snapshot (lock-free)
996    pub fn config(&self) -> arc_swap::Guard<Arc<DynamicConfig>> {
997        self.config.load()
998    }
999
1000    /// Get a specific agent config
1001    pub fn agent(&self, name: &str) -> Option<ToonAgentConfig> {
1002        self.config.load().get_agent(name).cloned()
1003    }
1004
1005    /// Get a specific model config
1006    pub fn model(&self, name: &str) -> Option<ToonModelConfig> {
1007        self.config.load().get_model(name).cloned()
1008    }
1009
1010    /// Get a specific tool config
1011    pub fn tool(&self, name: &str) -> Option<ToonToolConfig> {
1012        self.config.load().get_tool(name).cloned()
1013    }
1014
1015    /// Get a specific workflow config
1016    pub fn workflow(&self, name: &str) -> Option<ToonWorkflowConfig> {
1017        self.config.load().get_workflow(name).cloned()
1018    }
1019
1020    /// Get a specific MCP config
1021    pub fn mcp(&self, name: &str) -> Option<ToonMcpConfig> {
1022        self.config.load().get_mcp(name).cloned()
1023    }
1024
1025    /// Get all agents
1026    pub fn agents(&self) -> Vec<ToonAgentConfig> {
1027        self.config.load().agents.values().cloned().collect()
1028    }
1029
1030    /// Get all models
1031    pub fn models(&self) -> Vec<ToonModelConfig> {
1032        self.config.load().models.values().cloned().collect()
1033    }
1034
1035    /// Get all tools
1036    pub fn tools(&self) -> Vec<ToonToolConfig> {
1037        self.config.load().tools.values().cloned().collect()
1038    }
1039
1040    /// Get all workflows
1041    pub fn workflows(&self) -> Vec<ToonWorkflowConfig> {
1042        self.config.load().workflows.values().cloned().collect()
1043    }
1044
1045    /// Get all MCPs
1046    pub fn mcps(&self) -> Vec<ToonMcpConfig> {
1047        self.config.load().mcps.values().cloned().collect()
1048    }
1049
1050    /// Get all agent names
1051    pub fn agent_names(&self) -> Vec<String> {
1052        self.config
1053            .load()
1054            .agent_names()
1055            .into_iter()
1056            .map(String::from)
1057            .collect()
1058    }
1059
1060    /// Get all model names
1061    pub fn model_names(&self) -> Vec<String> {
1062        self.config
1063            .load()
1064            .model_names()
1065            .into_iter()
1066            .map(String::from)
1067            .collect()
1068    }
1069
1070    /// Get all tool names
1071    pub fn tool_names(&self) -> Vec<String> {
1072        self.config
1073            .load()
1074            .tool_names()
1075            .into_iter()
1076            .map(String::from)
1077            .collect()
1078    }
1079
1080    /// Get all workflow names
1081    pub fn workflow_names(&self) -> Vec<String> {
1082        self.config
1083            .load()
1084            .workflow_names()
1085            .into_iter()
1086            .map(String::from)
1087            .collect()
1088    }
1089
1090    /// Get all MCP names
1091    pub fn mcp_names(&self) -> Vec<String> {
1092        self.config
1093            .load()
1094            .mcp_names()
1095            .into_iter()
1096            .map(String::from)
1097            .collect()
1098    }
1099
1100    /// Hot-swap a single agent config in the in-memory cache (used for rollback).
1101    /// Does not write to disk — disk files are the canonical source on next restart.
1102    pub fn upsert_agent(&self, agent: ToonAgentConfig) {
1103        let current = self.config.load();
1104        let mut new_agents = current.agents.clone();
1105        new_agents.insert(agent.name.clone(), agent);
1106        let new_config = DynamicConfig {
1107            agents: new_agents,
1108            models: current.models.clone(),
1109            tools: current.tools.clone(),
1110            workflows: current.workflows.clone(),
1111            mcps: current.mcps.clone(),
1112        };
1113        self.config.store(Arc::new(new_config));
1114    }
1115}
1116
1117impl cordis::Service for DynamicConfigManager {
1118    fn name(&self) -> &'static str { "dynamic_config_manager" }
1119    fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
1120        // Overlay::watch_cordis owns the single watch_many_with for TOON dirs.
1121        Box::pin(async { Ok(None) })
1122    }
1123    fn check(&self) -> bool { true }
1124}
1125
1126/// Notify Tools and Execute TypeIds after a TOON reload.
1127///
1128/// Overlay's `watch_many_with` callback calls this instead of starting a
1129/// second notify watcher on [`DynamicConfigManager`].
1130pub(crate) fn notify_tools_and_execute(ctx: &Arc<cordis::Context>) {
1131    if let Some(reflect) = ctx.get::<cordis::ReflectService>() {
1132        reflect.notify(TypeId::of::<ares_tools::Tools>());
1133        reflect.notify(TypeId::of::<ares_agent::Execute>());
1134    }
1135}
1136
1137fn json_extra_to_toml(extra: &HashMap<String, serde_json::Value>) -> HashMap<String, toml::Value> {
1138    extra
1139        .iter()
1140        .filter_map(|(k, v)| {
1141            serde_json::from_value::<toml::Value>(v.clone())
1142                .ok()
1143                .map(|tv| (k.clone(), tv))
1144        })
1145        .collect()
1146}
1147
1148fn toon_to_agent_config(t: &ToonAgentConfig) -> ares_agent::AgentConfig {
1149    ares_agent::AgentConfig {
1150        model: t.model.clone(),
1151        system_prompt: t.system_prompt.clone(),
1152        tools: t.tools.clone(),
1153        allowed_tools: t.allowed_tools.clone(),
1154        max_tool_iterations: t.max_tool_iterations,
1155        parallel_tools: t.parallel_tools,
1156        compaction_enabled: None,
1157        extra: json_extra_to_toml(&t.extra),
1158    }
1159}
1160
1161impl ares_agent::ToonAgents for DynamicConfigManager {
1162    fn get(&self, name: &str) -> Option<ares_agent::AgentConfig> {
1163        self.agent(name).as_ref().map(toon_to_agent_config)
1164    }
1165    fn names(&self) -> Vec<String> {
1166        self.agent_names()
1167    }
1168}
1169
1170// ============= Tests =============
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use tempfile::TempDir;
1176
1177    #[test]
1178    fn test_agent_config_roundtrip() {
1179        let agent = ToonAgentConfig::new("test-agent", "fast")
1180            .with_system_prompt("You are a test agent.")
1181            .with_tools(vec!["calculator".to_string(), "web_search".to_string()]);
1182
1183        let toon = agent.to_toon().expect("Failed to encode");
1184        let decoded = ToonAgentConfig::from_toon(&toon).expect("Failed to decode");
1185
1186        assert_eq!(agent.name, decoded.name);
1187        assert_eq!(agent.model, decoded.model);
1188        assert_eq!(agent.system_prompt, decoded.system_prompt);
1189        assert_eq!(agent.tools, decoded.tools);
1190    }
1191
1192    #[test]
1193    fn test_model_config_roundtrip() {
1194        let model = ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b");
1195
1196        let toon = model.to_toon().expect("Failed to encode");
1197        let decoded = ToonModelConfig::from_toon(&toon).expect("Failed to decode");
1198
1199        assert_eq!(model.name, decoded.name);
1200        assert_eq!(model.provider, decoded.provider);
1201        assert_eq!(model.model, decoded.model);
1202        assert_eq!(model.temperature, decoded.temperature);
1203        assert_eq!(model.max_tokens, decoded.max_tokens);
1204    }
1205
1206    #[test]
1207    fn test_tool_config_roundtrip() {
1208        let mut tool = ToonToolConfig::new("calculator");
1209        tool.description = Some("Performs arithmetic operations".to_string());
1210        tool.timeout_secs = 10;
1211
1212        let toon = tool.to_toon().expect("Failed to encode");
1213        let decoded = ToonToolConfig::from_toon(&toon).expect("Failed to decode");
1214
1215        assert_eq!(tool.name, decoded.name);
1216        assert_eq!(tool.enabled, decoded.enabled);
1217        assert_eq!(tool.description, decoded.description);
1218        assert_eq!(tool.timeout_secs, decoded.timeout_secs);
1219    }
1220
1221    #[test]
1222    fn test_workflow_config_roundtrip() {
1223        let mut workflow = ToonWorkflowConfig::new("default", "router");
1224        workflow.fallback_agent = Some("orchestrator".to_string());
1225        workflow.max_depth = 3;
1226        workflow.max_iterations = 5;
1227
1228        let toon = workflow.to_toon().expect("Failed to encode");
1229        let decoded = ToonWorkflowConfig::from_toon(&toon).expect("Failed to decode");
1230
1231        assert_eq!(workflow.name, decoded.name);
1232        assert_eq!(workflow.entry_agent, decoded.entry_agent);
1233        assert_eq!(workflow.fallback_agent, decoded.fallback_agent);
1234        assert_eq!(workflow.max_depth, decoded.max_depth);
1235        assert_eq!(workflow.max_iterations, decoded.max_iterations);
1236    }
1237
1238    #[test]
1239    fn test_mcp_config_roundtrip() {
1240        let mut mcp = ToonMcpConfig::new("filesystem", "npx");
1241        mcp.args = vec![
1242            "-y".to_string(),
1243            "@modelcontextprotocol/server-filesystem".to_string(),
1244            "/home".to_string(),
1245            "/tmp".to_string(),
1246        ];
1247        mcp.env
1248            .insert("NODE_ENV".to_string(), "production".to_string());
1249        mcp.timeout_secs = 30;
1250
1251        let toon = mcp.to_toon().expect("Failed to encode");
1252        let decoded = ToonMcpConfig::from_toon(&toon).expect("Failed to decode");
1253
1254        assert_eq!(mcp.name, decoded.name);
1255        assert_eq!(mcp.command, decoded.command);
1256        assert_eq!(mcp.args, decoded.args);
1257        assert_eq!(mcp.env, decoded.env);
1258        assert_eq!(mcp.timeout_secs, decoded.timeout_secs);
1259    }
1260
1261    #[test]
1262    fn test_load_configs_from_dir() {
1263        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1264        let agents_dir = temp_dir.path().join("agents");
1265        fs::create_dir_all(&agents_dir).expect("Failed to create agents dir");
1266
1267        // Create a test agent TOON file
1268        let agent_content = r#"name: test-agent
1269model: fast
1270max_tool_iterations: 5
1271parallel_tools: false
1272tools[0]:
1273system_prompt: Test agent prompt"#;
1274
1275        fs::write(agents_dir.join("test-agent.toon"), agent_content)
1276            .expect("Failed to write agent file");
1277
1278        let agents = load_configs_from_dir::<ToonAgentConfig>(&agents_dir, "agents")
1279            .expect("Failed to load agents");
1280
1281        assert_eq!(agents.len(), 1);
1282        let agent = agents.get("test-agent").expect("Agent not found");
1283        assert_eq!(agent.name, "test-agent");
1284        assert_eq!(agent.model, "fast");
1285        assert_eq!(agent.max_tool_iterations, 5);
1286    }
1287
1288    #[test]
1289    fn test_load_toml_shaped_toon_config_from_dir() {
1290        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1291        let mcps_dir = temp_dir.path().join("mcps");
1292        fs::create_dir_all(&mcps_dir).expect("Failed to create mcps dir");
1293
1294        let mcp_content = r#"name = "eruka"
1295enabled = true
1296endpoint = "https://eruka.dirmacs.com/mcp"
1297transport = "http"
1298timeout_secs = 30
1299"#;
1300
1301        fs::write(mcps_dir.join("eruka.toon"), mcp_content).expect("Failed to write mcp file");
1302
1303        let mcps =
1304            load_configs_from_dir::<ToonMcpConfig>(&mcps_dir, "mcps").expect("Failed to load mcps");
1305
1306        let mcp = mcps.get("eruka").expect("MCP not found");
1307        assert_eq!(mcp.name, "eruka");
1308        assert!(mcp.enabled);
1309        assert_eq!(mcp.timeout_secs, 30);
1310    }
1311
1312    #[test]
1313    fn test_dynamic_config_validation() {
1314        let mut config = DynamicConfig::default();
1315
1316        // Add a model
1317        config.models.insert(
1318            "fast".to_string(),
1319            ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b"),
1320        );
1321
1322        // Add a tool
1323        config
1324            .tools
1325            .insert("calculator".to_string(), ToonToolConfig::new("calculator"));
1326
1327        // Add an agent that uses the model and tool
1328        let mut agent = ToonAgentConfig::new("router", "fast");
1329        agent.tools = vec!["calculator".to_string()];
1330        config.agents.insert("router".to_string(), agent);
1331
1332        // Add a workflow that uses the agent
1333        config.workflows.insert(
1334            "default".to_string(),
1335            ToonWorkflowConfig::new("default", "router"),
1336        );
1337
1338        // Validation should pass
1339        let warnings = config.validate().expect("Validation failed");
1340        assert!(warnings.is_empty());
1341    }
1342
1343    #[test]
1344    fn test_dynamic_config_validation_missing_model() {
1345        let mut config = DynamicConfig::default();
1346
1347        // Add an agent that references a non-existent model
1348        let agent = ToonAgentConfig::new("router", "non-existent-model");
1349        config.agents.insert("router".to_string(), agent);
1350
1351        let err = config.validate().expect_err("expected validation error");
1352        assert_eq!(
1353            err.to_string(),
1354            "Validation error: Agent 'router' references unknown model 'non-existent-model'"
1355        );
1356        match err {
1357            ToonConfigError::Validation(msg) => {
1358                assert_eq!(
1359                    msg,
1360                    "Agent 'router' references unknown model 'non-existent-model'"
1361                );
1362            }
1363            other => panic!("expected Validation error, got {other:?}"),
1364        }
1365    }
1366
1367    #[test]
1368    fn test_dynamic_config_validation_missing_tool() {
1369        let mut config = DynamicConfig::default();
1370
1371        // Add a model
1372        config.models.insert(
1373            "fast".to_string(),
1374            ToonModelConfig::new("fast", "ollama-local", "ministral-3:3b"),
1375        );
1376
1377        // Add an agent that references a non-existent tool
1378        let mut agent = ToonAgentConfig::new("router", "fast");
1379        agent.tools = vec!["non-existent-tool".to_string()];
1380        config.agents.insert("router".to_string(), agent);
1381
1382        let err = config.validate().expect_err("expected validation error");
1383        match err {
1384            ToonConfigError::Validation(msg) => {
1385                assert_eq!(
1386                    msg,
1387                    "Agent 'router' references unknown tool 'non-existent-tool'"
1388                );
1389            }
1390            other => panic!("expected Validation error, got {other:?}"),
1391        }
1392    }
1393
1394    #[test]
1395    fn test_dynamic_config_validation_missing_entry_agent() {
1396        let mut config = DynamicConfig::default();
1397        config.workflows.insert(
1398            "default".to_string(),
1399            ToonWorkflowConfig::new("default", "missing-agent"),
1400        );
1401
1402        let err = config.validate().expect_err("expected validation error");
1403        assert_eq!(
1404            err.to_string(),
1405            "Validation error: Workflow 'default' references unknown entry agent 'missing-agent'"
1406        );
1407    }
1408
1409    #[test]
1410    fn test_dynamic_config_validation_missing_fallback_agent() {
1411        let mut config = DynamicConfig::default();
1412        config
1413            .agents
1414            .insert("router".to_string(), ToonAgentConfig::new("router", "fast"));
1415        config.models.insert(
1416            "fast".to_string(),
1417            ToonModelConfig::new("fast", "ollama", "llama3"),
1418        );
1419
1420        let mut workflow = ToonWorkflowConfig::new("default", "router");
1421        workflow.fallback_agent = Some("missing-fallback".to_string());
1422        config.workflows.insert("default".to_string(), workflow);
1423
1424        let err = config.validate().expect_err("expected validation error");
1425        assert_eq!(
1426            err.to_string(),
1427            "Validation error: Workflow 'default' references unknown fallback agent 'missing-fallback'"
1428        );
1429    }
1430
1431    #[test]
1432    fn test_dynamic_config_validation_unused_model_and_tool_warnings() {
1433        let mut config = DynamicConfig::default();
1434        config.models.insert(
1435            "fast".to_string(),
1436            ToonModelConfig::new("fast", "ollama", "llama3"),
1437        );
1438        config.models.insert(
1439            "slow".to_string(),
1440            ToonModelConfig::new("slow", "ollama", "llama3:70b"),
1441        );
1442        config
1443            .tools
1444            .insert("calc".to_string(), ToonToolConfig::new("calc"));
1445        config
1446            .tools
1447            .insert("search".to_string(), ToonToolConfig::new("search"));
1448
1449        let mut agent = ToonAgentConfig::new("router", "fast");
1450        agent.tools = vec!["calc".to_string()];
1451        config.agents.insert("router".to_string(), agent);
1452
1453        let warnings = config.validate().expect("validation should succeed");
1454        assert_eq!(warnings.len(), 2);
1455
1456        let unused_model = warnings
1457            .iter()
1458            .find(|w| w.kind == WarningKind::UnusedModel)
1459            .expect("unused model warning");
1460        assert_eq!(
1461            unused_model.message,
1462            "Model 'slow' is not used by any agent"
1463        );
1464        assert_eq!(unused_model.to_string(), unused_model.message);
1465
1466        let unused_tool = warnings
1467            .iter()
1468            .find(|w| w.kind == WarningKind::UnusedTool)
1469            .expect("unused tool warning");
1470        assert_eq!(
1471            unused_tool.message,
1472            "Tool 'search' is not used by any agent"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_parse_agent_from_toon_string() {
1478        let toon = r#"name: router
1479model: fast
1480max_tool_iterations: 1
1481parallel_tools: false
1482tools[0]:
1483system_prompt: You are a routing agent."#;
1484
1485        let agent = ToonAgentConfig::from_toon(toon).expect("Failed to parse");
1486        assert_eq!(agent.name, "router");
1487        assert_eq!(agent.model, "fast");
1488        assert_eq!(agent.max_tool_iterations, 1);
1489        assert!(!agent.parallel_tools);
1490        assert!(agent.tools.is_empty());
1491    }
1492
1493    #[test]
1494    fn test_parse_model_from_toon_string() {
1495        let toon = r#"name: fast
1496provider: ollama-local
1497model: ministral-3:3b
1498temperature: 0.7
1499max_tokens: 256"#;
1500
1501        let model = ToonModelConfig::from_toon(toon).expect("Failed to parse");
1502        assert_eq!(model.name, "fast");
1503        assert_eq!(model.provider, "ollama-local");
1504        assert_eq!(model.model, "ministral-3:3b");
1505        assert!((model.temperature - 0.7).abs() < 0.01);
1506        assert_eq!(model.max_tokens, 256);
1507    }
1508    #[test]
1509    fn test_toon_agent_config_defaults() {
1510        let agent = ToonAgentConfig::new("router", "fast");
1511        assert_eq!(agent.version, "0.1.0");
1512        assert_eq!(agent.max_tool_iterations, 10);
1513        assert!(!agent.parallel_tools);
1514        assert!(agent.tools.is_empty());
1515        assert!(agent.system_prompt.is_none());
1516    }
1517
1518    #[test]
1519    fn test_toon_model_config_defaults() {
1520        let model = ToonModelConfig::new("fast", "ollama", "llama3");
1521        assert!((model.temperature - 0.7).abs() < 0.01);
1522        assert_eq!(model.max_tokens, 512);
1523    }
1524
1525    #[test]
1526    fn test_toon_tool_config_defaults() {
1527        let tool = ToonToolConfig::new("calc");
1528        assert!(tool.enabled);
1529        assert_eq!(tool.timeout_secs, 30);
1530    }
1531
1532    #[test]
1533    fn test_toon_workflow_config_defaults() {
1534        let wf = ToonWorkflowConfig::new("main", "router");
1535        assert_eq!(wf.max_depth, 3);
1536        assert_eq!(wf.max_iterations, 5);
1537        assert!(wf.fallback_agent.is_none());
1538    }
1539
1540    #[test]
1541    fn test_parse_tool_from_toon_string() {
1542        let toon = r#"name: calculator
1543enabled: true
1544timeout_secs: 15
1545description: Performs arithmetic"#;
1546
1547        let tool = ToonToolConfig::from_toon(toon).expect("Failed to parse");
1548        assert_eq!(tool.name, "calculator");
1549        assert!(tool.enabled);
1550        assert_eq!(tool.timeout_secs, 15);
1551        assert_eq!(
1552            tool.description.as_deref(),
1553            Some("Performs arithmetic")
1554        );
1555    }
1556
1557    #[test]
1558    fn test_parse_workflow_from_toon_string() {
1559        let toon = r#"name: default
1560entry_agent: router
1561fallback_agent: orchestrator
1562max_depth: 2
1563max_iterations: 4
1564parallel_subagents: true"#;
1565
1566        let workflow = ToonWorkflowConfig::from_toon(toon).expect("Failed to parse");
1567        assert_eq!(workflow.name, "default");
1568        assert_eq!(workflow.entry_agent, "router");
1569        assert_eq!(workflow.fallback_agent.as_deref(), Some("orchestrator"));
1570        assert_eq!(workflow.max_depth, 2);
1571        assert_eq!(workflow.max_iterations, 4);
1572        assert!(workflow.parallel_subagents);
1573    }
1574
1575    #[test]
1576    fn test_parse_agent_applies_serde_defaults_from_toon() {
1577        let toon = r#"name: minimal
1578model: fast"#;
1579
1580        let agent = ToonAgentConfig::from_toon(toon).expect("Failed to parse");
1581        assert_eq!(agent.version, "0.1.0");
1582        assert_eq!(agent.max_tool_iterations, 10);
1583        assert!(!agent.parallel_tools);
1584        assert!(agent.tools.is_empty());
1585    }
1586
1587    #[test]
1588    fn test_parse_invalid_toon_returns_parse_error() {
1589        let err = ToonAgentConfig::from_toon("name: [unclosed").expect_err("expected parse error");
1590        assert!(err.to_string().starts_with("TOON parse error: "));
1591        match err {
1592            ToonConfigError::Parse(msg) => assert!(!msg.is_empty()),
1593            other => panic!("expected Parse error, got {other:?}"),
1594        }
1595    }
1596
1597    #[test]
1598    fn test_load_toon_file_invalid_content() {
1599        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1600        let path = temp_dir.path().join("broken.toon");
1601        fs::write(&path, "name: [unclosed").expect("Failed to write broken toon file");
1602
1603        let err = load_toon_file::<ToonAgentConfig>(&path).expect_err("expected parse error");
1604        let msg = err.to_string();
1605        assert!(msg.contains("Failed to parse"), "{msg}");
1606        assert!(msg.contains("broken.toon"), "{msg}");
1607        assert!(msg.contains("TOON"), "{msg}");
1608        assert!(msg.contains("TOML"), "{msg}");
1609    }
1610
1611    #[test]
1612    fn test_load_configs_from_dir_missing_directory_returns_empty() {
1613        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1614        let missing = temp_dir.path().join("does-not-exist");
1615
1616        let agents =
1617            load_configs_from_dir::<ToonAgentConfig>(&missing, "agents").expect("should succeed");
1618        assert!(agents.is_empty());
1619    }
1620
1621    #[test]
1622    fn test_load_configs_from_dir_skips_non_toon_files() {
1623        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1624        let agents_dir = temp_dir.path().join("agents");
1625        fs::create_dir_all(&agents_dir).expect("Failed to create agents dir");
1626
1627        fs::write(agents_dir.join("notes.txt"), "ignore me").expect("Failed to write txt file");
1628        fs::write(
1629            agents_dir.join("valid.toon"),
1630            "name: router
1631model: fast
1632",
1633        )
1634        .expect("Failed to write toon file");
1635
1636        let agents = load_configs_from_dir::<ToonAgentConfig>(&agents_dir, "agents")
1637            .expect("Failed to load agents");
1638        assert_eq!(agents.len(), 1);
1639        assert!(agents.contains_key("router"));
1640    }
1641
1642    #[test]
1643    fn test_toon_mcp_config_defaults() {
1644        let mcp = ToonMcpConfig::new("filesystem", "npx");
1645        assert!(mcp.enabled);
1646        assert_eq!(mcp.timeout_secs, 30);
1647        assert_eq!(mcp.command.as_deref(), Some("npx"));
1648        assert!(mcp.args.is_empty());
1649        assert!(mcp.env.is_empty());
1650    }
1651
1652    #[test]
1653    fn test_dynamic_config_paths_resolve_under_cwd() {
1654        let paths = crate::overlay::DynamicConfigPaths::default();
1655        assert!(paths.agents_dir.is_relative());
1656        assert!(paths.hot_reload);
1657    }
1658
1659
1660    #[test]
1661    fn test_dynamic_config_load_from_directories() {
1662        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1663        let root = temp_dir.path();
1664
1665        let agents_dir = root.join("agents");
1666        let models_dir = root.join("models");
1667        let tools_dir = root.join("tools");
1668        let workflows_dir = root.join("workflows");
1669        let mcps_dir = root.join("mcps");
1670        for dir in [&agents_dir, &models_dir, &tools_dir, &workflows_dir, &mcps_dir] {
1671            fs::create_dir_all(dir).expect("Failed to create config dir");
1672        }
1673
1674        fs::write(
1675            agents_dir.join("router.toon"),
1676            "name: router\nmodel: fast\ntools[1]: calc\n",
1677        )
1678        .expect("Failed to write agent");
1679        fs::write(
1680            models_dir.join("fast.toon"),
1681            "name: fast\nprovider: ollama\nmodel: llama3\n",
1682        )
1683        .expect("Failed to write model");
1684        fs::write(tools_dir.join("calc.toon"), "name: calc\n").expect("Failed to write tool");
1685        fs::write(
1686            workflows_dir.join("default.toon"),
1687            "name: default\nentry_agent: router\n",
1688        )
1689        .expect("Failed to write workflow");
1690        fs::write(mcps_dir.join("fs.toon"), "name: fs\ncommand: npx\n")
1691            .expect("Failed to write mcp");
1692
1693        let config = DynamicConfig::load(
1694            &agents_dir,
1695            &models_dir,
1696            &tools_dir,
1697            &workflows_dir,
1698            &mcps_dir,
1699        )
1700        .expect("Failed to load dynamic config");
1701
1702        assert_eq!(config.agents.len(), 1);
1703        assert_eq!(config.models.len(), 1);
1704        assert_eq!(config.tools.len(), 1);
1705        assert_eq!(config.workflows.len(), 1);
1706        assert_eq!(config.mcps.len(), 1);
1707        assert!(config.validate().expect("validation failed").is_empty());
1708    }
1709
1710    #[test]
1711    fn test_dynamic_config_accessors_and_name_lists() {
1712        let mut config = DynamicConfig::default();
1713        config
1714            .agents
1715            .insert("router".to_string(), ToonAgentConfig::new("router", "fast"));
1716        config.models.insert(
1717            "fast".to_string(),
1718            ToonModelConfig::new("fast", "ollama", "llama3"),
1719        );
1720        config
1721            .tools
1722            .insert("calc".to_string(), ToonToolConfig::new("calc"));
1723        config.workflows.insert(
1724            "default".to_string(),
1725            ToonWorkflowConfig::new("default", "router"),
1726        );
1727        config
1728            .mcps
1729            .insert("fs".to_string(), ToonMcpConfig::new("fs", "npx"));
1730
1731        assert_eq!(config.get_agent("router").unwrap().name, "router");
1732        assert_eq!(config.get_model("fast").unwrap().provider, "ollama");
1733        assert_eq!(config.get_tool("calc").unwrap().name, "calc");
1734        assert_eq!(config.get_workflow("default").unwrap().entry_agent, "router");
1735        assert_eq!(config.get_mcp("fs").unwrap().name, "fs");
1736        assert!(config.get_agent("missing").is_none());
1737
1738        assert_eq!(config.agent_names(), vec!["router"]);
1739        assert_eq!(config.model_names(), vec!["fast"]);
1740        assert_eq!(config.tool_names(), vec!["calc"]);
1741        assert_eq!(config.workflow_names(), vec!["default"]);
1742        assert_eq!(config.mcp_names(), vec!["fs"]);
1743    }
1744
1745    #[test]
1746    fn test_dynamic_config_manager_without_hot_reload() {
1747        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1748        let root = temp_dir.path();
1749
1750        let agents_dir = root.join("agents");
1751        let models_dir = root.join("models");
1752        let tools_dir = root.join("tools");
1753        let workflows_dir = root.join("workflows");
1754        let mcps_dir = root.join("mcps");
1755        fs::create_dir_all(&agents_dir).expect("Failed to create agents dir");
1756        fs::create_dir_all(&models_dir).expect("Failed to create models dir");
1757        fs::create_dir_all(&tools_dir).expect("Failed to create tools dir");
1758        fs::create_dir_all(&workflows_dir).expect("Failed to create workflows dir");
1759        fs::create_dir_all(&mcps_dir).expect("Failed to create mcps dir");
1760
1761        fs::write(
1762            agents_dir.join("router.toon"),
1763            "name: router\nmodel: fast\n",
1764        )
1765        .expect("Failed to write agent");
1766        fs::write(
1767            models_dir.join("fast.toon"),
1768            "name: fast\nprovider: ollama\nmodel: llama3\n",
1769        )
1770        .expect("Failed to write model");
1771
1772        let manager = DynamicConfigManager::new(
1773            agents_dir,
1774            models_dir,
1775            tools_dir,
1776            workflows_dir,
1777            mcps_dir,
1778            false,
1779        )
1780        .expect("Failed to create manager");
1781
1782        let agent = manager.agent("router").expect("router agent missing");
1783        assert_eq!(agent.model, "fast");
1784        assert_eq!(manager.agent_names(), vec!["router"]);
1785        assert_eq!(manager.model_names(), vec!["fast"]);
1786        assert!(manager.tool_names().is_empty());
1787        assert!(manager.workflow_names().is_empty());
1788        assert!(manager.mcps().is_empty());
1789
1790        let warnings = manager.reload().expect("reload failed");
1791        assert!(warnings.is_empty());
1792    }
1793
1794    #[test]
1795    fn dynamic_config_manager_readable_via_cordis() {
1796        use cordis::Service;
1797        let dir = TempDir::new().unwrap();
1798        let manager = DynamicConfigManager::new(
1799            dir.path().join("agents"),
1800            dir.path().join("models"),
1801            dir.path().join("tools"),
1802            dir.path().join("workflows"),
1803            dir.path().join("mcps"),
1804            false,
1805        )
1806        .expect("empty dynamic config");
1807        let ctx = std::sync::Arc::new(cordis::Context::new_root());
1808        ctx.provide(manager);
1809        let got = ctx.get::<DynamicConfigManager>().expect("provided");
1810        assert_eq!(got.name(), "dynamic_config_manager");
1811        assert!(got.check());
1812    }
1813
1814    #[test]
1815    fn toon_changes_notify_tools_and_execute_type_ids() {
1816        let ctx = cordis::Context::new_root();
1817        let reflect = ctx.provide(cordis::ReflectService::new());
1818        let mut tools_rx = reflect.ensure_notifier(TypeId::of::<ares_tools::Tools>());
1819        let mut execute_rx = reflect.ensure_notifier(TypeId::of::<ares_agent::Execute>());
1820        let _ = tools_rx.borrow_and_update();
1821        let _ = execute_rx.borrow_and_update();
1822
1823        super::notify_tools_and_execute(&ctx);
1824
1825        assert!(
1826            tools_rx.has_changed().expect("tools watch"),
1827            "TOON notify must signal TypeId::of::<Tools>()"
1828        );
1829        assert!(
1830            execute_rx.has_changed().expect("execute watch"),
1831            "TOON notify must signal TypeId::of::<Execute>()"
1832        );
1833    }
1834
1835}