1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use ai_agents_core::{AgentError, AgentStorage, Result, Tool};
8use ai_agents_llm::LLMRegistry;
9
10use crate::AgentBuilder;
11use crate::spec::{AgentSpec, SpawnerConfig, TemplateSource};
12
13use super::registry::AgentRegistry;
14use super::spawner::{AgentSpawner, ResolvedTemplate};
15use super::tools::{GenerateAgentTool, ListAgentsTool, RemoveAgentTool, SendMessageTool};
16
17fn extract_template_metadata(raw: &str) -> (Option<String>, Option<HashMap<String, String>>) {
19 let val: serde_yaml::Value = match serde_yaml::from_str(raw) {
21 Ok(v) => v,
22 Err(_) => return (None, None),
23 };
24
25 let description = val
28 .get("description")
29 .and_then(|v| v.as_str())
30 .map(String::from);
31
32 let variables = val
34 .get("metadata")
35 .and_then(|m| m.get("template"))
36 .and_then(|t| t.get("variables"))
37 .and_then(|v| v.as_mapping())
38 .map(|mapping| {
39 mapping
40 .iter()
41 .filter_map(|(k, v)| {
42 let key = k.as_str()?.to_string();
43 let val = v.as_str()?.to_string();
44 Some((key, val))
45 })
46 .collect()
47 });
48
49 (description, variables)
50}
51
52pub fn resolve_templates(
54 templates: &HashMap<String, TemplateSource>,
55 base_dir: Option<&Path>,
56) -> Result<HashMap<String, ResolvedTemplate>> {
57 let mut resolved = HashMap::with_capacity(templates.len());
58 for (name, source) in templates {
59 let content = match source {
60 TemplateSource::Inline(s) => s.clone(),
61 TemplateSource::File { path } => {
62 let full_path = resolve_path(path, base_dir);
63 std::fs::read_to_string(&full_path).map_err(|e| {
64 AgentError::Config(format!(
65 "Failed to read template '{}' from '{}': {}",
66 name,
67 full_path.display(),
68 e
69 ))
70 })?
71 }
72 };
73
74 let (description, variables) = extract_template_metadata(&content);
75
76 resolved.insert(
77 name.clone(),
78 ResolvedTemplate {
79 content,
80 description,
81 variables,
82 },
83 );
84 }
85 Ok(resolved)
86}
87
88fn resolve_path(path: &str, base_dir: Option<&Path>) -> PathBuf {
90 let p = Path::new(path);
91 if p.is_absolute() {
92 p.to_path_buf()
93 } else {
94 match base_dir {
97 Some(dir) => dir.join(p),
98 None => p.to_path_buf(),
99 }
100 }
101}
102
103pub fn spawner_from_config(
105 config: &SpawnerConfig,
106 llm_registry: Option<LLMRegistry>,
107 storage: Option<Arc<dyn AgentStorage>>,
108 base_dir: Option<&Path>,
109) -> Result<AgentSpawner> {
110 let mut spawner = AgentSpawner::new();
111
112 if config.shared_llms {
113 let registry = llm_registry.ok_or_else(|| {
114 AgentError::Config(
115 "spawner.shared_llms requires the parent LLM registry to be configured".to_string(),
116 )
117 })?;
118 spawner = spawner.with_shared_llms(registry);
119 }
120
121 if let Some(st) = storage {
123 spawner = spawner.with_shared_storage(st);
124 }
125
126 if !config.shared_context.is_empty() {
127 spawner = spawner.with_shared_context_map(config.shared_context.clone());
128 }
129
130 if let Some(max) = config.max_agents {
131 spawner = spawner.with_max_agents(max);
132 }
133
134 if let Some(ref prefix) = config.name_prefix {
135 spawner = spawner.with_name_prefix(prefix.clone())?;
136 }
137
138 if !config.templates.is_empty() {
140 let resolved = resolve_templates(&config.templates, base_dir)?;
141 spawner = spawner.with_templates(resolved);
142 }
143
144 if let Some(ref allowed) = config.allowed_tools {
145 spawner = spawner.with_allowed_tools(allowed.clone());
146 }
147
148 Ok(spawner)
149}
150
151pub fn configure_spawner_tools(
153 spawner: Arc<AgentSpawner>,
154 registry: Arc<AgentRegistry>,
155 llm: Arc<LLMRegistry>,
156 sender_id: impl Into<String>,
157) -> Vec<Arc<dyn Tool>> {
158 vec![
159 Arc::new(GenerateAgentTool::new(
160 Arc::clone(&spawner),
161 Arc::clone(®istry),
162 llm,
163 )),
164 Arc::new(SendMessageTool::new(Arc::clone(®istry), sender_id)),
165 Arc::new(ListAgentsTool::new(Arc::clone(®istry))),
166 Arc::new(RemoveAgentTool::new(Arc::clone(®istry))),
167 ]
168}
169
170pub async fn auto_configure_spawner(
173 mut builder: AgentBuilder,
174 spec: &AgentSpec,
175 llm_registry: Option<&LLMRegistry>,
176 base_dir: Option<&Path>,
177) -> Result<(
178 AgentBuilder,
179 Option<(Arc<AgentSpawner>, Arc<AgentRegistry>)>,
180)> {
181 let spawner_config = match spec.spawner {
182 Some(ref c) => c,
183 None => return Ok((builder, None)),
184 };
185
186 let mut spawner = AgentSpawner::new().with_resource_locks(builder.shared_resource_locks());
187
188 if spawner_config.shared_llms {
189 let registry = llm_registry.ok_or_else(|| {
190 AgentError::Config(
191 "spawner.shared_llms requires the parent LLM registry to be configured".to_string(),
192 )
193 })?;
194 spawner = spawner.with_shared_llms(registry.clone());
195 }
196
197 if !spawner_config.shared_context.is_empty() {
198 spawner = spawner.with_shared_context_map(spawner_config.shared_context.clone());
199 }
200
201 if let Some(max) = spawner_config.max_agents {
202 spawner = spawner.with_max_agents(max);
203 }
204
205 if let Some(ref prefix) = spawner_config.name_prefix {
206 spawner = spawner.with_name_prefix(prefix.clone())?;
207 }
208
209 if !spawner_config.templates.is_empty() {
211 let resolved = resolve_templates(&spawner_config.templates, base_dir)?;
212 spawner = spawner.with_templates(resolved);
213 }
214
215 if let Some(ref allowed) = spawner_config.allowed_tools {
216 spawner = spawner.with_allowed_tools(allowed.clone());
217 }
218
219 if let Some(ref sc) = spawner_config.shared_storage {
221 let converted = crate::spec::storage::to_storage_config(sc);
222 if let Some(st) = ai_agents_storage::create_storage(&converted).await? {
223 spawner = spawner.with_shared_storage(st);
224 }
225 }
226
227 let spawner = Arc::new(spawner);
228 let registry = Arc::new(AgentRegistry::new());
229
230 let llm_for_tools = Arc::new(llm_registry.cloned().unwrap_or_default());
231
232 let tools = configure_spawner_tools(
233 Arc::clone(&spawner),
234 Arc::clone(®istry),
235 llm_for_tools,
236 &spec.name,
237 );
238
239 for tool in tools {
240 builder = builder.tool(tool);
241 }
242
243 Ok((builder, Some((spawner, registry))))
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::spec::{ManagementToolsConfig, OrchestrationToolsConfig};
250
251 #[test]
254 fn test_extract_metadata_full() {
255 let raw = r#"
256name: "{{ name }}"
257description: "A test NPC template"
258metadata:
259 template:
260 variables:
261 role: "NPC occupation"
262 personality: "Personality description"
263system_prompt: "You are {{ name }}."
264"#;
265 let (desc, vars) = extract_template_metadata(raw);
266 assert_eq!(desc.as_deref(), Some("A test NPC template"));
267 let vars = vars.unwrap();
268 assert_eq!(vars.get("role").unwrap(), "NPC occupation");
269 assert_eq!(vars.get("personality").unwrap(), "Personality description");
270 }
271
272 #[test]
273 fn test_extract_metadata_no_metadata() {
274 let raw = "name: \"{{ name }}\"\nsystem_prompt: \"hello\"";
275 let (desc, vars) = extract_template_metadata(raw);
276 assert!(desc.is_none());
277 assert!(vars.is_none());
278 }
279
280 #[test]
281 fn test_extract_metadata_description_only() {
282 let raw = "name: test\ndescription: \"Just a description\"\nsystem_prompt: hi";
283 let (desc, vars) = extract_template_metadata(raw);
284 assert_eq!(desc.as_deref(), Some("Just a description"));
285 assert!(vars.is_none());
286 }
287
288 #[test]
289 fn test_extract_metadata_variables_only() {
290 let raw = r#"
291name: "{{ name }}"
292metadata:
293 template:
294 variables:
295 department: "Support department"
296system_prompt: "You work in {{ department }}."
297"#;
298 let (desc, vars) = extract_template_metadata(raw);
299 assert!(desc.is_none());
300 let vars = vars.unwrap();
301 assert_eq!(vars.len(), 1);
302 assert_eq!(vars.get("department").unwrap(), "Support department");
303 }
304
305 #[test]
306 fn test_extract_metadata_invalid_yaml() {
307 let raw = "{{{{ totally broken yaml";
308 let (desc, vars) = extract_template_metadata(raw);
309 assert!(desc.is_none());
310 assert!(vars.is_none());
311 }
312
313 #[test]
314 fn test_extract_metadata_with_jinja2_expressions() {
315 let raw = r#"
316name: "{{ name }}"
317description: "NPC template"
318metadata:
319 template:
320 variables:
321 role: "occupation"
322system_prompt: "You are {{ name }}, a {{ role }}."
323"#;
324 let (desc, vars) = extract_template_metadata(raw);
325 assert_eq!(desc.as_deref(), Some("NPC template"));
326 assert!(vars.is_some());
327 assert_eq!(vars.unwrap().get("role").unwrap(), "occupation");
328 }
329
330 #[test]
331 fn test_resolve_templates_inline_extracts_metadata() {
332 let mut templates = HashMap::new();
333 templates.insert(
334 "agent".to_string(),
335 TemplateSource::Inline(
336 "name: test\ndescription: \"Inline agent\"\nsystem_prompt: hi".to_string(),
337 ),
338 );
339 let resolved = resolve_templates(&templates, None).unwrap();
340 let tpl = resolved.get("agent").unwrap();
341 assert_eq!(tpl.description.as_deref(), Some("Inline agent"));
342 assert!(tpl.content.contains("name: test"));
343 }
344
345 #[test]
346 fn test_resolve_templates_no_metadata_backward_compat() {
347 let mut templates = HashMap::new();
348 templates.insert(
349 "bare".to_string(),
350 TemplateSource::Inline("name: test\nsystem_prompt: hi".to_string()),
351 );
352 let resolved = resolve_templates(&templates, None).unwrap();
353 let tpl = resolved.get("bare").unwrap();
354 assert!(tpl.description.is_none());
355 assert!(tpl.variables.is_none());
356 assert!(tpl.content.contains("name: test"));
357 }
358
359 #[test]
360 fn test_resolve_templates_file_extracts_metadata() {
361 let dir = std::env::temp_dir().join("ai_agents_test_tpl_meta");
362 let _ = std::fs::create_dir_all(&dir);
363 let tpl = dir.join("npc.yaml");
364 std::fs::write(
365 &tpl,
366 r#"
367name: "{{ name }}"
368description: "Test NPC"
369metadata:
370 template:
371 variables:
372 role: "occupation"
373system_prompt: "You are {{ name }}."
374"#,
375 )
376 .unwrap();
377
378 let mut templates = HashMap::new();
379 templates.insert(
380 "npc".to_string(),
381 TemplateSource::File {
382 path: "npc.yaml".to_string(),
383 },
384 );
385 let resolved = resolve_templates(&templates, Some(&dir)).unwrap();
386 let rt = resolved.get("npc").unwrap();
387 assert_eq!(rt.description.as_deref(), Some("Test NPC"));
388 assert_eq!(
389 rt.variables.as_ref().unwrap().get("role").unwrap(),
390 "occupation"
391 );
392 assert!(rt.content.contains("{{ name }}"));
393
394 let _ = std::fs::remove_dir_all(&dir);
395 }
396
397 #[test]
398 fn test_resolve_templates_file_path() {
399 let dir = std::env::temp_dir().join("ai_agents_test_resolve_tpl_24");
400 let _ = std::fs::create_dir_all(&dir);
401 let tpl_path = dir.join("npc.yaml");
402 std::fs::write(&tpl_path, "name: {{ name }}\nsystem_prompt: hi").unwrap();
403
404 let mut templates = HashMap::new();
405 templates.insert(
406 "npc".to_string(),
407 TemplateSource::File {
408 path: "npc.yaml".to_string(),
409 },
410 );
411 let resolved = resolve_templates(&templates, Some(&dir)).unwrap();
412 assert!(resolved.get("npc").unwrap().content.contains("{{ name }}"));
413
414 let _ = std::fs::remove_dir_all(&dir);
415 }
416
417 #[test]
418 fn test_resolve_templates_absolute_path() {
419 let dir = std::env::temp_dir().join("ai_agents_test_resolve_abs_24");
420 let _ = std::fs::create_dir_all(&dir);
421 let tpl_path = dir.join("guard.yaml");
422 std::fs::write(&tpl_path, "name: Guard\nsystem_prompt: hi").unwrap();
423
424 let mut templates = HashMap::new();
425 templates.insert(
426 "guard".to_string(),
427 TemplateSource::File {
428 path: tpl_path.to_str().unwrap().to_string(),
429 },
430 );
431 let resolved = resolve_templates(&templates, None).unwrap();
432 assert!(
433 resolved
434 .get("guard")
435 .unwrap()
436 .content
437 .contains("name: Guard")
438 );
439
440 let _ = std::fs::remove_dir_all(&dir);
441 }
442
443 #[test]
444 fn test_resolve_templates_missing_file_errors() {
445 let mut templates = HashMap::new();
446 templates.insert(
447 "missing".to_string(),
448 TemplateSource::File {
449 path: "./nonexistent_template_abc123.yaml".to_string(),
450 },
451 );
452 let result = resolve_templates(&templates, None);
453 assert!(result.is_err());
454 let err = result.unwrap_err().to_string();
455 assert!(err.contains("missing"));
456 assert!(err.contains("nonexistent_template_abc123.yaml"));
457 }
458
459 #[test]
460 fn test_resolve_templates_mixed() {
461 let dir = std::env::temp_dir().join("ai_agents_test_resolve_mixed_24");
462 let _ = std::fs::create_dir_all(&dir);
463 let tpl_path = dir.join("from_file.yaml");
464 std::fs::write(&tpl_path, "name: FromFile\nsystem_prompt: hi").unwrap();
465
466 let mut templates = HashMap::new();
467 templates.insert(
468 "inline".to_string(),
469 TemplateSource::Inline("name: Inline\nsystem_prompt: hi".to_string()),
470 );
471 templates.insert(
472 "file".to_string(),
473 TemplateSource::File {
474 path: "from_file.yaml".to_string(),
475 },
476 );
477
478 let resolved = resolve_templates(&templates, Some(&dir)).unwrap();
479 assert!(resolved.get("inline").unwrap().content.contains("Inline"));
480 assert!(resolved.get("file").unwrap().content.contains("FromFile"));
481
482 let _ = std::fs::remove_dir_all(&dir);
483 }
484
485 #[test]
486 fn test_resolve_path_relative_with_base() {
487 let base = Path::new("/home/user/agents");
488 let result = resolve_path("templates/npc.yaml", Some(base));
489 assert_eq!(
490 result,
491 PathBuf::from("/home/user/agents/templates/npc.yaml")
492 );
493 }
494
495 #[test]
496 fn test_resolve_path_relative_dot_slash() {
497 let base = Path::new("/home/user/agents");
498 let result = resolve_path("./templates/npc.yaml", Some(base));
499 assert_eq!(
500 result,
501 PathBuf::from("/home/user/agents/./templates/npc.yaml")
502 );
503 }
504
505 #[test]
506 fn test_resolve_path_absolute_ignores_base() {
507 let base = Path::new("/home/user/agents");
508 let result = resolve_path("/opt/templates/npc.yaml", Some(base));
509 assert_eq!(result, PathBuf::from("/opt/templates/npc.yaml"));
510 }
511
512 #[test]
513 fn test_resolve_path_no_base_returns_raw() {
514 let result = resolve_path("templates/npc.yaml", None);
515 assert_eq!(result, PathBuf::from("templates/npc.yaml"));
516 }
517
518 #[test]
521 fn test_spawner_from_empty_config() {
522 let config = SpawnerConfig::default();
523 let spawner = spawner_from_config(&config, None, None, None).unwrap();
524 assert_eq!(spawner.spawned_count(), 0);
525 }
526
527 #[test]
528 fn test_spawner_from_populated_config() {
529 let mut templates = HashMap::new();
530 templates.insert(
531 "base".to_string(),
532 TemplateSource::Inline(
533 "name: {{ name }}\ndescription: \"Base\"\nsystem_prompt: hello".to_string(),
534 ),
535 );
536
537 let mut shared_ctx = HashMap::new();
538 shared_ctx.insert("world".to_string(), serde_json::json!("Fantasy"));
539
540 let config = SpawnerConfig {
541 shared_llms: false,
542 shared_storage: None,
543 shared_context: shared_ctx,
544 max_agents: Some(10),
545 name_prefix: Some("npc_".to_string()),
546 templates,
547 allowed_tools: Some(vec!["echo".to_string()]),
548 auto_spawn: Vec::new(),
549 management_tools: ManagementToolsConfig::default(),
550 orchestration_tools: OrchestrationToolsConfig::default(),
551 };
552
553 let spawner = spawner_from_config(&config, None, None, None).unwrap();
554 let tpl = spawner.templates().get("base").unwrap();
555 assert!(tpl.content.contains("{{ name }}"));
556 assert_eq!(tpl.description.as_deref(), Some("Base"));
557 }
558
559 #[test]
560 fn test_spawner_from_config_with_storage() {
561 let storage: Arc<dyn AgentStorage> =
562 Arc::new(ai_agents_storage::FileStorage::new("/tmp/test_spawner_cfg"));
563 let config = SpawnerConfig {
564 shared_storage: Some(crate::spec::StorageConfig::sqlite("./test.db")),
565 ..Default::default()
566 };
567 let spawner = spawner_from_config(&config, None, Some(storage), None).unwrap();
568 assert!(spawner.shared_storage().is_some());
569 }
570
571 #[test]
572 fn test_spawner_from_config_without_storage() {
573 let config = SpawnerConfig::default();
574 let spawner = spawner_from_config(&config, None, None, None).unwrap();
575 assert!(spawner.shared_storage().is_none());
576 }
577}