enki_runtime/config/
mesh_config.rs

1//! Mesh configuration from TOML files.
2
3use super::{AgentConfig, MemoryConfig};
4use crate::core::error::{Error, Result};
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8/// Configuration for a multi-agent mesh.
9///
10/// This struct allows defining multiple agents in a single TOML file.
11///
12/// # Example
13///
14/// ```toml
15/// name = "research_mesh"
16///
17/// [memory]
18/// backend = "sqlite"
19/// path = "./data/knowledge.db"
20///
21/// [[agents]]
22/// name = "researcher"
23/// model = "ollama::gemma3:latest"
24/// system_prompt = "You are a research assistant."
25/// temperature = 0.7
26///
27/// [[agents]]
28/// name = "summarizer"
29/// model = "ollama::gemma3:latest"
30/// system_prompt = "You are a summarization expert."
31/// temperature = 0.5
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MeshConfig {
35    /// Name of the mesh
36    pub name: String,
37
38    /// Version of the mesh configuration
39    #[serde(default)]
40    pub version: Option<String>,
41
42    /// Name of the agent to use as coordinator (must exist in agents list)
43    /// Takes precedence over auto_coordinator if both are set
44    #[serde(default)]
45    pub coordinator: Option<String>,
46
47    /// If true, mesh will auto-generate a coordinator agent with orchestration capabilities
48    /// Only used if coordinator is not explicitly set
49    #[serde(default)]
50    pub auto_coordinator: bool,
51
52    /// Default model for auto-generated coordinator (defaults to first agent's model)
53    #[serde(default)]
54    pub coordinator_model: Option<String>,
55
56    /// Shared memory configuration for all agents (can be overridden per-agent)
57    #[serde(default)]
58    pub memory: Option<MemoryConfig>,
59
60    /// List of agent configurations
61    #[serde(default)]
62    pub agents: Vec<AgentConfig>,
63}
64
65impl Default for MeshConfig {
66    fn default() -> Self {
67        Self {
68            name: "mesh".to_string(),
69            version: None,
70            coordinator: None,
71            auto_coordinator: false,
72            coordinator_model: None,
73            memory: None,
74            agents: Vec::new(),
75        }
76    }
77}
78
79impl MeshConfig {
80    /// Create a new MeshConfig with a name.
81    pub fn new(name: impl Into<String>) -> Self {
82        Self {
83            name: name.into(),
84            version: None,
85            coordinator: None,
86            auto_coordinator: false,
87            coordinator_model: None,
88            memory: None,
89            agents: Vec::new(),
90        }
91    }
92
93    /// Parse a MeshConfig from a TOML string.
94    ///
95    /// # Example
96    ///
97    /// ```rust
98    /// use enki_runtime::config::MeshConfig;
99    ///
100    /// let toml = r#"
101    /// name = "my_mesh"
102    ///
103    /// [[agents]]
104    /// name = "agent1"
105    /// model = "ollama::llama2"
106    /// "#;
107    ///
108    /// let config = MeshConfig::from_toml(toml).unwrap();
109    /// assert_eq!(config.name, "my_mesh");
110    /// assert_eq!(config.agents.len(), 1);
111    /// ```
112    pub fn from_toml(toml_str: &str) -> Result<Self> {
113        toml::from_str(toml_str)
114            .map_err(|e| Error::ConfigError(format!("Failed to parse TOML: {}", e)))
115    }
116
117    /// Load a MeshConfig from a TOML file.
118    ///
119    /// # Example
120    ///
121    /// ```rust,no_run
122    /// use enki_runtime::config::MeshConfig;
123    ///
124    /// let config = MeshConfig::from_file("agents.toml").unwrap();
125    /// ```
126    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
127        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
128            Error::ConfigError(format!(
129                "Failed to read file '{}': {}",
130                path.as_ref().display(),
131                e
132            ))
133        })?;
134        Self::from_toml(&content)
135    }
136
137    /// Add an agent configuration.
138    pub fn add_agent(mut self, agent: AgentConfig) -> Self {
139        self.agents.push(agent);
140        self
141    }
142
143    /// Get agent config by name.
144    pub fn get_agent(&self, name: &str) -> Option<&AgentConfig> {
145        self.agents.iter().find(|a| a.name == name)
146    }
147
148    /// Get the coordinator agent configuration.
149    ///
150    /// Priority order:
151    /// 1. Explicitly set `coordinator` field (must exist in agents list)
152    /// 2. First agent in the list (default behavior)
153    ///
154    /// Returns None if no agents are defined.
155    pub fn get_coordinator(&self) -> Option<&AgentConfig> {
156        // If coordinator is explicitly set, find it
157        if let Some(ref coordinator_name) = self.coordinator {
158            return self.get_agent(coordinator_name);
159        }
160
161        // Check if there's an auto-generated coordinator
162        if self.auto_coordinator {
163            if let Some(coord) = self.get_agent("coordinator") {
164                return Some(coord);
165            }
166        }
167
168        // Fallback to first agent
169        self.agents.first()
170    }
171
172    /// Get the name of the coordinator agent.
173    pub fn get_coordinator_name(&self) -> Option<&str> {
174        self.get_coordinator().map(|a| a.name.as_str())
175    }
176
177    /// Ensure a coordinator agent exists if auto_coordinator is enabled.
178    ///
179    /// This creates a built-in coordinator agent with orchestration capabilities
180    /// that knows about all other agents in the mesh.
181    ///
182    /// Call this after loading the config and before using the mesh.
183    pub fn ensure_coordinator(&mut self) {
184        // Skip if coordinator is explicitly set
185        if self.coordinator.is_some() {
186            return;
187        }
188
189        // Skip if auto_coordinator is not enabled
190        if !self.auto_coordinator {
191            return;
192        }
193
194        // Skip if coordinator already exists
195        if self.get_agent("coordinator").is_some() {
196            return;
197        }
198
199        // Determine the model to use for coordinator
200        let model = self
201            .coordinator_model
202            .clone()
203            .or_else(|| self.agents.first().map(|a| a.model.clone()))
204            .unwrap_or_else(|| "ollama::gemma3:latest".to_string());
205
206        // Build list of available agents for the system prompt
207        let agent_list: Vec<String> = self
208            .agents
209            .iter()
210            .map(|a| {
211                let desc = if a.system_prompt.len() > 100 {
212                    format!("{}...", &a.system_prompt[..100])
213                } else {
214                    a.system_prompt.clone()
215                };
216                format!("- {}: {}", a.name, desc)
217            })
218            .collect();
219
220        let system_prompt = format!(
221            r#"You are a coordinator agent responsible for orchestrating tasks across multiple specialized agents.
222
223Your role is to:
2241. Understand user requests and break them down into subtasks
2252. Delegate subtasks to the most appropriate specialized agent
2263. Synthesize responses from multiple agents when needed
2274. Provide cohesive, helpful responses to the user
228
229Available agents in this mesh:
230{}
231
232When delegating tasks, consider each agent's specialization and choose the best fit.
233If a task requires multiple agents, coordinate their efforts and combine their outputs."#,
234            agent_list.join("\n")
235        );
236
237        let coordinator = AgentConfig::new("coordinator", model)
238            .with_system_prompt(system_prompt)
239            .with_temperature(0.7);
240
241        // Insert coordinator at the beginning
242        self.agents.insert(0, coordinator);
243    }
244
245    /// Set the coordinator agent by name.
246    pub fn with_coordinator(mut self, name: impl Into<String>) -> Self {
247        self.coordinator = Some(name.into());
248        self
249    }
250
251    /// Enable auto-coordinator with optional model.
252    pub fn with_auto_coordinator(mut self, model: Option<String>) -> Self {
253        self.auto_coordinator = true;
254        self.coordinator_model = model;
255        self
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_parse_mesh_config() {
265        let toml = r#"
266            name = "test_mesh"
267
268            [[agents]]
269            name = "agent1"
270            model = "ollama::llama2"
271            system_prompt = "First agent"
272
273            [[agents]]
274            name = "agent2"
275            model = "openai::gpt-4"
276            temperature = 0.8
277        "#;
278
279        let config = MeshConfig::from_toml(toml).unwrap();
280        assert_eq!(config.name, "test_mesh");
281        assert_eq!(config.agents.len(), 2);
282        assert_eq!(config.agents[0].name, "agent1");
283        assert_eq!(config.agents[1].name, "agent2");
284        assert_eq!(config.agents[1].temperature, Some(0.8));
285    }
286
287    #[test]
288    fn test_get_agent() {
289        let config = MeshConfig::new("test")
290            .add_agent(AgentConfig::new("agent1", "ollama::llama2"))
291            .add_agent(AgentConfig::new("agent2", "ollama::llama2"));
292
293        assert!(config.get_agent("agent1").is_some());
294        assert!(config.get_agent("agent2").is_some());
295        assert!(config.get_agent("nonexistent").is_none());
296    }
297
298    #[test]
299    fn test_empty_agents() {
300        let toml = r#"
301            name = "empty_mesh"
302        "#;
303
304        let config = MeshConfig::from_toml(toml).unwrap();
305        assert_eq!(config.name, "empty_mesh");
306        assert!(config.agents.is_empty());
307    }
308
309    #[test]
310    fn test_get_coordinator_default_first_agent() {
311        let config = MeshConfig::new("test")
312            .add_agent(AgentConfig::new("agent1", "ollama::llama2"))
313            .add_agent(AgentConfig::new("agent2", "ollama::llama2"));
314
315        // Default: first agent is coordinator
316        let coordinator = config.get_coordinator();
317        assert!(coordinator.is_some());
318        assert_eq!(coordinator.unwrap().name, "agent1");
319    }
320
321    #[test]
322    fn test_get_coordinator_designated() {
323        let config = MeshConfig::new("test")
324            .add_agent(AgentConfig::new("agent1", "ollama::llama2"))
325            .add_agent(AgentConfig::new("agent2", "ollama::llama2"))
326            .with_coordinator("agent2");
327
328        // Designated coordinator takes precedence
329        let coordinator = config.get_coordinator();
330        assert!(coordinator.is_some());
331        assert_eq!(coordinator.unwrap().name, "agent2");
332    }
333
334    #[test]
335    fn test_ensure_coordinator_auto() {
336        let mut config = MeshConfig::new("test")
337            .add_agent(AgentConfig::new("worker1", "ollama::llama2"))
338            .add_agent(AgentConfig::new("worker2", "ollama::llama2"))
339            .with_auto_coordinator(None);
340
341        // Before ensure_coordinator
342        assert_eq!(config.agents.len(), 2);
343
344        // After ensure_coordinator
345        config.ensure_coordinator();
346        assert_eq!(config.agents.len(), 3);
347
348        // Coordinator should be first
349        assert_eq!(config.agents[0].name, "coordinator");
350
351        // get_coordinator should return the auto-generated coordinator
352        let coordinator = config.get_coordinator();
353        assert!(coordinator.is_some());
354        assert_eq!(coordinator.unwrap().name, "coordinator");
355
356        // System prompt should mention available agents
357        assert!(config.agents[0].system_prompt.contains("worker1"));
358        assert!(config.agents[0].system_prompt.contains("worker2"));
359    }
360
361    #[test]
362    fn test_ensure_coordinator_skips_if_designated() {
363        let mut config = MeshConfig::new("test")
364            .add_agent(AgentConfig::new("agent1", "ollama::llama2"))
365            .add_agent(AgentConfig::new("agent2", "ollama::llama2"))
366            .with_coordinator("agent1")
367            .with_auto_coordinator(None);
368
369        config.auto_coordinator = true; // Also set auto_coordinator
370
371        // Should not create auto coordinator when designated exists
372        config.ensure_coordinator();
373        assert_eq!(config.agents.len(), 2); // No new agent added
374    }
375
376    #[test]
377    fn test_parse_coordinator_from_toml() {
378        let toml = r#"
379            name = "test_mesh"
380            coordinator = "agent2"
381
382            [[agents]]
383            name = "agent1"
384            model = "ollama::llama2"
385
386            [[agents]]
387            name = "agent2"
388            model = "ollama::llama2"
389        "#;
390
391        let config = MeshConfig::from_toml(toml).unwrap();
392        assert_eq!(config.coordinator, Some("agent2".to_string()));
393
394        let coordinator = config.get_coordinator();
395        assert_eq!(coordinator.unwrap().name, "agent2");
396    }
397
398    #[test]
399    fn test_parse_auto_coordinator_from_toml() {
400        let toml = r#"
401            name = "test_mesh"
402            auto_coordinator = true
403            coordinator_model = "ollama::gemma3:latest"
404
405            [[agents]]
406            name = "worker1"
407            model = "ollama::llama2"
408        "#;
409
410        let mut config = MeshConfig::from_toml(toml).unwrap();
411        assert!(config.auto_coordinator);
412        assert_eq!(
413            config.coordinator_model,
414            Some("ollama::gemma3:latest".to_string())
415        );
416
417        config.ensure_coordinator();
418        assert_eq!(config.agents.len(), 2);
419        assert_eq!(config.agents[0].name, "coordinator");
420        assert_eq!(config.agents[0].model, "ollama::gemma3:latest");
421    }
422}