Skip to main content

ai_agents_runtime/spec/
spawner.rs

1//! Spawner configuration types for YAML deserialization.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use super::StorageConfig;
7
8/// An agent to create at startup and register in the AgentRegistry.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AutoSpawnEntry {
11    /// Registry ID for this agent.
12    pub id: String,
13    /// Path to the agent YAML file (resolved relative to parent YAML directory).
14    pub agent: String,
15}
16
17/// Orchestration tool selection: all tools or a specific subset.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum OrchestrationToolsConfig {
21    /// `orchestration_tools: true` registers all orchestration tools.
22    All(bool),
23    /// `orchestration_tools: [route_to_agent, group_discussion]` registers listed tools.
24    Selected(Vec<String>),
25}
26
27impl Default for OrchestrationToolsConfig {
28    fn default() -> Self {
29        Self::All(false)
30    }
31}
32
33impl OrchestrationToolsConfig {
34    /// Returns true if any orchestration tools are enabled.
35    pub fn is_enabled(&self) -> bool {
36        match self {
37            Self::All(v) => *v,
38            Self::Selected(v) => !v.is_empty(),
39        }
40    }
41
42    /// Returns true if the given tool name is included.
43    pub fn includes(&self, tool_name: &str) -> bool {
44        match self {
45            Self::All(true) => true,
46            Self::All(false) => false,
47            Self::Selected(v) => v.iter().any(|t| t == tool_name),
48        }
49    }
50}
51
52/// Configuration for dynamic agent spawning declared in the `spawner:` YAML section.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SpawnerConfig {
55    /// When true, spawned agents reuse the parent agent's LLM connections.
56    #[serde(default)]
57    pub shared_llms: bool,
58
59    /// Shared storage backend for all spawned agents.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub shared_storage: Option<StorageConfig>,
62
63    /// Context values injected into every spawned agent.
64    #[serde(default)]
65    pub shared_context: HashMap<String, serde_json::Value>,
66
67    /// Maximum number of agents that can be spawned.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub max_agents: Option<usize>,
70
71    /// Auto-naming prefix for spawned agents (e.g. "npc_" -> "npc_001").
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub name_prefix: Option<String>,
74
75    /// Named YAML templates -- inline strings or file path references.
76    #[serde(default)]
77    pub templates: HashMap<String, TemplateSource>,
78
79    /// Tool names that spawned agents are allowed to use.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub allowed_tools: Option<Vec<String>>,
82
83    /// Agents to create at startup and register in the AgentRegistry.
84    #[serde(default)]
85    pub auto_spawn: Vec<AutoSpawnEntry>,
86
87    /// Register orchestration tools (route_to_agent, group_discussion, etc.).
88    #[serde(default)]
89    pub orchestration_tools: OrchestrationToolsConfig,
90}
91
92/// A spawner template source: either an inline YAML string or a file path reference.
93///
94/// Untagged:
95/// Serde tries `File` first (object with `path` key), falls back to `Inline` (plain string).
96/// File paths are resolved against the parent YAML directory at config time.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum TemplateSource {
100    /// File-based template: `{ path: "./templates/npc.yaml" }`.
101    File { path: String },
102    /// Inline YAML template string (backward compatible).
103    Inline(String),
104}
105
106impl TemplateSource {
107    /// Returns true if this is a file path reference.
108    pub fn is_file(&self) -> bool {
109        matches!(self, Self::File { .. })
110    }
111
112    /// Returns true if this is an inline template string.
113    pub fn is_inline(&self) -> bool {
114        matches!(self, Self::Inline(_))
115    }
116}
117
118impl Default for SpawnerConfig {
119    fn default() -> Self {
120        Self {
121            shared_llms: false,
122            shared_storage: None,
123            shared_context: HashMap::new(),
124            max_agents: None,
125            name_prefix: None,
126            templates: HashMap::new(),
127            allowed_tools: None,
128            auto_spawn: Vec::new(),
129            orchestration_tools: OrchestrationToolsConfig::default(),
130        }
131    }
132}
133
134impl SpawnerConfig {
135    /// Returns true if any spawner configuration is present.
136    pub fn is_configured(&self) -> bool {
137        self.shared_llms
138            || self.shared_storage.is_some()
139            || !self.shared_context.is_empty()
140            || self.max_agents.is_some()
141            || self.name_prefix.is_some()
142            || !self.templates.is_empty()
143            || !self.auto_spawn.is_empty()
144            || self.orchestration_tools.is_enabled()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_default_is_not_configured() {
154        let config = SpawnerConfig::default();
155        assert!(!config.is_configured());
156    }
157
158    #[test]
159    fn test_deserialize_inline_template() {
160        let yaml = r#"
161shared_llms: true
162max_agents: 50
163name_prefix: "npc_"
164shared_context:
165  world_name: "Medieval Fantasy"
166  current_era: "Age of Dragons"
167templates:
168  npc_base: |
169    name: "{{ name }}"
170    system_prompt: "You are {{ name }}."
171"#;
172        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
173        assert!(config.shared_llms);
174        assert_eq!(config.max_agents, Some(50));
175        assert_eq!(config.name_prefix.as_deref(), Some("npc_"));
176        assert_eq!(config.shared_context.len(), 2);
177        assert!(config.templates.contains_key("npc_base"));
178        assert!(config.templates.get("npc_base").unwrap().is_inline());
179        assert!(config.is_configured());
180    }
181
182    #[test]
183    fn test_deserialize_auto_spawn() {
184        let yaml = r#"
185shared_llms: true
186auto_spawn:
187  - id: billing
188    agent: agents/billing_agent.yaml
189  - id: technical
190    agent: agents/technical_agent.yaml
191"#;
192        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
193        assert_eq!(config.auto_spawn.len(), 2);
194        assert_eq!(config.auto_spawn[0].id, "billing");
195        assert_eq!(config.auto_spawn[0].agent, "agents/billing_agent.yaml");
196    }
197
198    #[test]
199    fn test_deserialize_orchestration_tools_all() {
200        let yaml = r#"
201orchestration_tools: true
202"#;
203        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
204        assert!(config.orchestration_tools.is_enabled());
205        assert!(config.orchestration_tools.includes("route_to_agent"));
206    }
207
208    #[test]
209    fn test_deserialize_orchestration_tools_selected() {
210        let yaml = r#"
211orchestration_tools:
212  - route_to_agent
213  - group_discussion
214"#;
215        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
216        assert!(config.orchestration_tools.is_enabled());
217        assert!(config.orchestration_tools.includes("route_to_agent"));
218        assert!(config.orchestration_tools.includes("group_discussion"));
219        assert!(!config.orchestration_tools.includes("concurrent_ask"));
220    }
221
222    #[test]
223    fn test_auto_spawn_makes_configured() {
224        let config = SpawnerConfig {
225            auto_spawn: vec![AutoSpawnEntry {
226                id: "test".to_string(),
227                agent: "test.yaml".to_string(),
228            }],
229            ..SpawnerConfig::default()
230        };
231        assert!(config.is_configured());
232    }
233
234    #[test]
235    fn test_deserialize_file_template() {
236        let yaml = r#"
237templates:
238  npc_base:
239    path: ./templates/npc_base.yaml
240"#;
241        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
242        match config.templates.get("npc_base") {
243            Some(TemplateSource::File { path }) => {
244                assert_eq!(path, "./templates/npc_base.yaml");
245            }
246            other => panic!("expected File variant, got {:?}", other),
247        }
248    }
249
250    #[test]
251    fn test_deserialize_mixed_templates() {
252        let yaml = r#"
253templates:
254  inline_one: |
255    name: "{{ name }}"
256  file_one:
257    path: ./templates/npc.yaml
258  inline_two: "name: test"
259"#;
260        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
261        assert!(config.templates.get("inline_one").unwrap().is_inline());
262        assert!(config.templates.get("file_one").unwrap().is_file());
263        assert!(config.templates.get("inline_two").unwrap().is_inline());
264    }
265
266    #[test]
267    fn test_deserialize_absolute_path_template() {
268        let yaml = r#"
269templates:
270  shared_guard:
271    path: /opt/game/shared_templates/guard.yaml
272"#;
273        let config: SpawnerConfig = serde_yaml::from_str(yaml).unwrap();
274        match config.templates.get("shared_guard") {
275            Some(TemplateSource::File { path }) => {
276                assert_eq!(path, "/opt/game/shared_templates/guard.yaml");
277            }
278            other => panic!("expected File variant, got {:?}", other),
279        }
280    }
281
282    #[test]
283    fn test_roundtrip_serde() {
284        let config = SpawnerConfig {
285            shared_llms: true,
286            shared_storage: None,
287            shared_context: HashMap::new(),
288            max_agents: Some(100),
289            name_prefix: Some("test_".to_string()),
290            templates: HashMap::new(),
291            allowed_tools: Some(vec!["echo".to_string()]),
292            auto_spawn: Vec::new(),
293            orchestration_tools: OrchestrationToolsConfig::default(),
294        };
295        let yaml = serde_yaml::to_string(&config).unwrap();
296        let parsed: SpawnerConfig = serde_yaml::from_str(&yaml).unwrap();
297        assert_eq!(parsed.max_agents, Some(100));
298        assert_eq!(parsed.name_prefix.as_deref(), Some("test_"));
299    }
300
301    #[test]
302    fn test_template_source_is_file() {
303        let file = TemplateSource::File {
304            path: "./test.yaml".to_string(),
305        };
306        let inline = TemplateSource::Inline("content".to_string());
307        assert!(file.is_file());
308        assert!(!file.is_inline());
309        assert!(inline.is_inline());
310        assert!(!inline.is_file());
311    }
312}