Skip to main content

ai_agents_runtime/spawner/
config.rs

1//! Build an AgentSpawner from a parsed SpawnerConfig and wire spawner tools.
2
3use 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
17/// Extract description and variable declarations from a raw template YAML string.
18fn extract_template_metadata(raw: &str) -> (Option<String>, Option<HashMap<String, String>>) {
19    // Parses the template as `serde_yaml::Value` (Jinja2 expressions are valid YAML strings)
20    let val: serde_yaml::Value = match serde_yaml::from_str(raw) {
21        Ok(v) => v,
22        Err(_) => return (None, None),
23    };
24
25    // reads `description` and `metadata.template.variables`.
26    // description: "..."
27    let description = val
28        .get("description")
29        .and_then(|v| v.as_str())
30        .map(String::from);
31
32    // metadata.template.variables: { role: "...", personality: "..." }
33    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
52/// Resolve a `TemplateSource` map into `ResolvedTemplate` values by reading files and extracting metadata.
53pub 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
88/// Resolve a path string against a base directory. Absolute paths are used as-is.
89fn 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        // File paths are resolved against `base_dir` (typically the parent YAML's directory).
95        // When `base_dir` is `None`, relative paths resolve against the current working directory.
96        match base_dir {
97            Some(dir) => dir.join(p),
98            None => p.to_path_buf(),
99        }
100    }
101}
102
103/// Construct an `AgentSpawner` from the `spawner:` section of an AgentSpec.
104pub 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    // Shared storage: caller resolves StorageConfig into Arc<dyn AgentStorage>.
122    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    // Resolve file-path templates and extract metadata before storing.
139    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
151/// Create the four spawner tools wired to the given spawner and registry.
152pub 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(&registry),
162            llm,
163        )),
164        Arc::new(SendMessageTool::new(Arc::clone(&registry), sender_id)),
165        Arc::new(ListAgentsTool::new(Arc::clone(&registry))),
166        Arc::new(RemoveAgentTool::new(Arc::clone(&registry))),
167    ]
168}
169
170/// Wire spawner tools into an AgentBuilder when the spec has a `spawner:` section.
171/// Call after `auto_configure_llms()` and `auto_configure_features()`.
172pub 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    // Resolve file-path templates and extract metadata before storing.
210    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    // Resolve shared storage from YAML config into a live backend.
220    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(&registry),
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    // -- extract_template_metadata tests --
252
253    #[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    // -- spawner_from_config tests --
519
520    #[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}