ai_agents_runtime/spec/
spawner.rs1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use super::StorageConfig;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AutoSpawnEntry {
11 pub id: String,
13 pub agent: String,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum OrchestrationToolsConfig {
21 All(bool),
23 Selected(Vec<String>),
25}
26
27impl Default for OrchestrationToolsConfig {
28 fn default() -> Self {
29 Self::All(false)
30 }
31}
32
33impl OrchestrationToolsConfig {
34 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SpawnerConfig {
55 #[serde(default)]
57 pub shared_llms: bool,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub shared_storage: Option<StorageConfig>,
62
63 #[serde(default)]
65 pub shared_context: HashMap<String, serde_json::Value>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub max_agents: Option<usize>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub name_prefix: Option<String>,
74
75 #[serde(default)]
77 pub templates: HashMap<String, TemplateSource>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub allowed_tools: Option<Vec<String>>,
82
83 #[serde(default)]
85 pub auto_spawn: Vec<AutoSpawnEntry>,
86
87 #[serde(default)]
89 pub orchestration_tools: OrchestrationToolsConfig,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum TemplateSource {
100 File { path: String },
102 Inline(String),
104}
105
106impl TemplateSource {
107 pub fn is_file(&self) -> bool {
109 matches!(self, Self::File { .. })
110 }
111
112 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 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}