Skip to main content

ai_agents_runtime/spawner/
spawner.rs

1//! Core agent spawner for creating agents at runtime.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU32, Ordering};
6
7use chrono::Utc;
8use minijinja::Environment;
9use tracing::info;
10
11use crate::AgentBuilder;
12use crate::RuntimeAgent;
13use crate::runtime::ToolResourceLocks;
14use crate::spec::AgentSpec;
15use ai_agents_core::{AgentError, AgentStorage, Result};
16use ai_agents_llm::LLMRegistry;
17use ai_agents_observability::ObservabilityManager;
18
19use super::storage::NamespacedStorage;
20
21/// A spawner template with its raw content and extracted metadata.
22#[derive(Debug, Clone)]
23pub struct ResolvedTemplate {
24    /// Raw Jinja2 template string for rendering.
25    pub content: String,
26    /// Template description extracted from the `description:` field.
27    pub description: Option<String>,
28    /// Variable name -> description map extracted from `metadata.template.variables`.
29    pub variables: Option<HashMap<String, String>>,
30}
31
32impl ResolvedTemplate {
33    /// Create a ResolvedTemplate from a plain content string with no metadata.
34    pub fn from_content(content: impl Into<String>) -> Self {
35        Self {
36            content: content.into(),
37            description: None,
38            variables: None,
39        }
40    }
41}
42
43/// Metadata for a spawned agent.
44pub struct SpawnedAgent {
45    /// Unique identifier (derived from spec name or auto-generated).
46    pub id: String,
47    /// The runtime agent, wrapped in Arc for shared ownership across registry callers.
48    pub agent: Arc<RuntimeAgent>,
49    /// Retained spec for introspection and serialization.
50    pub spec: AgentSpec,
51    /// Timestamp when the agent was created.
52    pub spawned_at: chrono::DateTime<Utc>,
53}
54
55impl std::fmt::Debug for SpawnedAgent {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("SpawnedAgent")
58            .field("id", &self.id)
59            .field("spawned_at", &self.spawned_at)
60            .finish_non_exhaustive()
61    }
62}
63
64/// Factory for creating agents at runtime from YAML, specs, or templates.
65pub struct AgentSpawner {
66    /// Shared LLM regstry - spawned agents reuse these connections.
67    llm_registry: Option<LLMRegistry>,
68
69    /// Whether shared LLM providers are already observability-wrapped.
70    llm_registry_observed: bool,
71
72    /// Shared storage backend with per-gaent `NamespacedStorage` warpping.
73    storage: Option<Arc<dyn AgentStorage>>,
74
75    /// Context values injected into every spawned agent.
76    shared_context: HashMap<String, serde_json::Value>,
77
78    /// Hard limit on the number of agents this spawner may create.
79    max_agents: Option<usize>,
80
81    /// Auto-naming prefix (e.g. "npc_" produces "npc_001", "npc_002").
82    name_prefix: Option<String>,
83
84    /// Named YAML templates with content and extracted metadata.
85    templates: HashMap<String, ResolvedTemplate>,
86
87    /// Tool names that spawned agents are allowed to declare.
88    allowed_tools: Option<Vec<String>>,
89
90    /// Shared resource locks inherited by spawned child agents.
91    resource_locks: Option<ToolResourceLocks>,
92
93    /// Shared observability manager for spawned child agents.
94    observability_manager: Option<Arc<ObservabilityManager>>,
95
96    /// Monotonic counter for auto-naming.
97    counter: AtomicU32,
98
99    /// Running count of agents spawned (for limit enforcement).
100    agent_count: AtomicU32,
101}
102
103impl AgentSpawner {
104    pub fn new() -> Self {
105        Self {
106            llm_registry: None,
107            llm_registry_observed: false,
108            storage: None,
109            shared_context: HashMap::new(),
110            max_agents: None,
111            name_prefix: None,
112            templates: HashMap::new(),
113            allowed_tools: None,
114            resource_locks: None,
115            observability_manager: None,
116            counter: AtomicU32::new(1),
117            agent_count: AtomicU32::new(0),
118        }
119    }
120
121    /// Share LLM connections across all spawned agents.
122    pub fn with_shared_llms(mut self, registry: LLMRegistry) -> Self {
123        self.llm_registry = Some(registry);
124        self.llm_registry_observed = false;
125        self
126    }
127
128    /// Share an LLM registry that is already wrapped by observed providers.
129    pub fn with_shared_observed_llms(mut self, registry: LLMRegistry) -> Self {
130        self.llm_registry = Some(registry);
131        self.llm_registry_observed = true;
132        self
133    }
134
135    /// Share a storage backend (e.g. one SQLite DB for all NPCs).
136    pub fn with_shared_storage(mut self, storage: Arc<dyn AgentStorage>) -> Self {
137        self.storage = Some(storage);
138        self
139    }
140
141    /// Inject a context value available to all spawned agents.
142    pub fn with_shared_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
143        self.shared_context.insert(key.into(), value);
144        self
145    }
146
147    /// Inject an entire map of shared context values.
148    pub fn with_shared_context_map(mut self, ctx: HashMap<String, serde_json::Value>) -> Self {
149        self.shared_context.extend(ctx);
150        self
151    }
152
153    /// Limit total spawned agents.
154    pub fn with_max_agents(mut self, max: usize) -> Self {
155        self.max_agents = Some(max);
156        self
157    }
158
159    /// Auto-name agents with prefix + zero-padded counter.
160    pub fn with_name_prefix(mut self, prefix: impl Into<String>) -> Self {
161        self.name_prefix = Some(prefix.into());
162        self
163    }
164
165    /// Register a named template from a plain YAML string (no metadata).
166    pub fn with_template(
167        mut self,
168        name: impl Into<String>,
169        yaml_template: impl Into<String>,
170    ) -> Self {
171        self.templates
172            .insert(name.into(), ResolvedTemplate::from_content(yaml_template));
173        self
174    }
175
176    /// Bulk-register resolved templates (with metadata already extracted).
177    pub fn with_templates(mut self, templates: HashMap<String, ResolvedTemplate>) -> Self {
178        self.templates.extend(templates);
179        self
180    }
181
182    /// Set the tool allowlist for spawned agents.
183    pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
184        self.allowed_tools = Some(tools);
185        self
186    }
187
188    /// Share the parent's resource lock table with spawned agents.
189    pub fn with_resource_locks(mut self, locks: ToolResourceLocks) -> Self {
190        self.resource_locks = Some(locks);
191        self
192    }
193
194    /// Share the parent's observability manager with spawned agents.
195    pub fn with_observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
196        self.observability_manager = Some(manager);
197        self
198    }
199
200    /// Spawn an agent from a YAML string.
201    pub async fn spawn_from_yaml(&self, yaml: &str) -> Result<SpawnedAgent> {
202        let mut spec: AgentSpec = serde_yaml::from_str(yaml)?;
203        spec.validate()?;
204        self.enforce_tool_allowlist(&mut spec);
205        self.spawn_from_spec(spec).await
206    }
207
208    /// Spawn an agent from a pre-built AgentSpec.
209    pub async fn spawn_from_spec(&self, spec: AgentSpec) -> Result<SpawnedAgent> {
210        let agent_id = self.generate_id(&spec.name);
211        self.spawn_inner(agent_id, spec).await
212    }
213
214    /// Spawn an agent with a specific ID, used for session restore.
215    pub async fn spawn_with_id(&self, id: String, spec: AgentSpec) -> Result<SpawnedAgent> {
216        self.spawn_inner(id, spec).await
217    }
218
219    /// Internal spawn with an explicit ID. All builder wiring lives here.
220    async fn spawn_inner(&self, agent_id: String, spec: AgentSpec) -> Result<SpawnedAgent> {
221        self.check_spawn_limit()?;
222
223        // Build the agent through the standard runtime pipeline.
224        let mut builder = AgentBuilder::from_spec(spec.clone());
225
226        // Inject shared LLM registry as a base; spec-specific LLMs override it.
227        if let Some(ref shared_reg) = self.llm_registry {
228            builder = if self.llm_registry_observed {
229                builder.observed_llm_registry(shared_reg.clone())
230            } else {
231                builder.llm_registry(shared_reg.clone())
232            };
233        }
234
235        // Auto-configure spec-specific LLMs only when the spec declares providers.
236        if !spec.llms.is_empty() {
237            builder = builder.auto_configure_llms()?;
238        }
239
240        // Wire up recovery, tool security, process pipeline, and built-in tools.
241        builder = builder.auto_configure_features()?;
242
243        if let Some(ref manager) = self.observability_manager {
244            builder = builder.observability(Arc::clone(manager));
245        }
246        if let Some(ref locks) = self.resource_locks {
247            builder = builder.with_shared_resource_locks(Arc::clone(locks));
248        }
249
250        // Shared storage with per-agent namespacing.
251        if let Some(ref shared_storage) = self.storage {
252            let namespaced = Arc::new(NamespacedStorage::new(
253                Arc::clone(shared_storage),
254                &agent_id,
255            ));
256            builder = builder.storage(namespaced);
257        }
258
259        let agent = builder.build()?;
260
261        // Inject shared context values into the agent's context manager.
262        for (key, value) in &self.shared_context {
263            let _ = agent.set_context(key, value.clone());
264        }
265
266        self.agent_count.fetch_add(1, Ordering::Relaxed);
267
268        info!(agent_id = %agent_id, name = %spec.name, "Agent spawned");
269
270        Ok(SpawnedAgent {
271            id: agent_id,
272            agent: Arc::new(agent),
273            spec,
274            spawned_at: Utc::now(),
275        })
276    }
277
278    /// Spawn from a named template with caller-provided variables.
279    ///
280    /// Template rendering merges two namespaces:
281    /// - Caller variables: top-level (`{{ name }}`, `{{ role }}`)
282    /// - Shared context: under `context.` prefix (`{{ context.world_name }}`)
283    pub async fn spawn_from_template(
284        &self,
285        template_name: &str,
286        variables: HashMap<String, String>,
287    ) -> Result<SpawnedAgent> {
288        let template = self.templates.get(template_name).ok_or_else(|| {
289            AgentError::Config(format!("Spawner template not found: {}", template_name))
290        })?;
291
292        let rendered = self.render_template(&template.content, &variables)?;
293        self.spawn_from_yaml(&rendered).await
294    }
295
296    /// Returns the current number of agents that have been spawned.
297    pub fn spawned_count(&self) -> u32 {
298        self.agent_count.load(Ordering::Relaxed)
299    }
300
301    /// Decrement the agent count (called when an agent is removed from the registry).
302    pub fn notify_agent_removed(&self) {
303        let prev = self.agent_count.load(Ordering::Relaxed);
304        if prev > 0 {
305            self.agent_count.fetch_sub(1, Ordering::Relaxed);
306        }
307    }
308
309    /// Returns a reference to the shared LLM registry, if configured.
310    pub fn llm_registry(&self) -> Option<&LLMRegistry> {
311        self.llm_registry.as_ref()
312    }
313
314    /// Returns a reference to the shared storage, if configured.
315    pub fn shared_storage(&self) -> Option<&Arc<dyn AgentStorage>> {
316        self.storage.as_ref()
317    }
318
319    /// Returns a reference to the resolved template map.
320    pub fn templates(&self) -> &HashMap<String, ResolvedTemplate> {
321        &self.templates
322    }
323
324    fn check_spawn_limit(&self) -> Result<()> {
325        if let Some(max) = self.max_agents {
326            let current = self.agent_count.load(Ordering::Relaxed) as usize;
327            if current >= max {
328                return Err(AgentError::Config(format!(
329                    "Spawn limit exceeded: {}/{}",
330                    current, max
331                )));
332            }
333        }
334        Ok(())
335    }
336
337    fn generate_id(&self, spec_name: &str) -> String {
338        if let Some(ref prefix) = self.name_prefix {
339            let n = self.counter.fetch_add(1, Ordering::Relaxed);
340            format!("{}{:03}", prefix, n)
341        } else {
342            spec_name.to_lowercase().replace(' ', "_")
343        }
344    }
345
346    /// Strip tools from the spec that are not in the allowlist.
347    fn enforce_tool_allowlist(&self, spec: &mut AgentSpec) {
348        if let Some(ref allowed) = self.allowed_tools {
349            if let Some(ref mut tools) = spec.tools {
350                let before = tools.len();
351                tools.retain(|t| allowed.contains(&t.name().to_string()));
352                let removed = before - tools.len();
353                if removed > 0 {
354                    tracing::warn!(
355                        removed_count = removed,
356                        "Stripped disallowed tools from spawned agent spec"
357                    );
358                }
359            }
360        }
361    }
362
363    /// Render a template string with caller variables and shared context.
364    fn render_template(
365        &self,
366        template_str: &str,
367        variables: &HashMap<String, String>,
368    ) -> Result<String> {
369        let mut env = Environment::new();
370        env.add_template("_spawn", template_str)
371            .map_err(|e| AgentError::TemplateError(format!("template parse error: {}", e)))?;
372
373        let tmpl = env
374            .get_template("_spawn")
375            .map_err(|e| AgentError::TemplateError(format!("template load error: {}", e)))?;
376
377        // Caller variables are top-level; shared context lives under "context".
378        let mut ctx = serde_json::Map::new();
379
380        for (k, v) in variables {
381            ctx.insert(k.clone(), serde_json::Value::String(v.clone()));
382        }
383
384        // Shared context as a nested object so {{ context.world_name }} works.
385        let context_obj = serde_json::Value::Object(
386            self.shared_context
387                .iter()
388                .map(|(k, v)| (k.clone(), v.clone()))
389                .collect(),
390        );
391        ctx.insert("context".to_string(), context_obj);
392
393        let ctx_value = serde_json::Value::Object(ctx);
394        let mj_value = minijinja::Value::from_serialize(&ctx_value);
395
396        tmpl.render(mj_value)
397            .map_err(|e| AgentError::TemplateError(format!("template render error: {}", e)))
398    }
399}
400
401impl Default for AgentSpawner {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_generate_id_with_prefix() {
413        let spawner = AgentSpawner::new().with_name_prefix("npc_");
414        assert_eq!(spawner.generate_id("Gormund"), "npc_001");
415        assert_eq!(spawner.generate_id("Elena"), "npc_002");
416    }
417
418    #[test]
419    fn test_generate_id_without_prefix() {
420        let spawner = AgentSpawner::new();
421        assert_eq!(spawner.generate_id("My Agent"), "my_agent");
422        assert_eq!(spawner.generate_id("TestBot"), "testbot");
423    }
424
425    #[test]
426    fn test_check_spawn_limit() {
427        let spawner = AgentSpawner::new().with_max_agents(2);
428        assert!(spawner.check_spawn_limit().is_ok());
429        spawner.agent_count.store(2, Ordering::Relaxed);
430        assert!(spawner.check_spawn_limit().is_err());
431    }
432
433    #[test]
434    fn test_render_template_basic() {
435        let spawner = AgentSpawner::new()
436            .with_shared_context("world_name", serde_json::json!("Fantasy Land"));
437
438        let template =
439            "name: {{ name }}\nsystem_prompt: You are {{ name }} in {{ context.world_name }}.";
440        let mut vars = HashMap::new();
441        vars.insert("name".to_string(), "Gormund".to_string());
442
443        let rendered = spawner.render_template(template, &vars).unwrap();
444        assert!(rendered.contains("name: Gormund"));
445        assert!(rendered.contains("Fantasy Land"));
446    }
447
448    #[test]
449    fn test_enforce_tool_allowlist() {
450        let spawner = AgentSpawner::new()
451            .with_allowed_tools(vec!["echo".to_string(), "calculator".to_string()]);
452
453        let yaml = r#"
454name: Test
455system_prompt: test
456tools:
457  - echo
458  - calculator
459  - file
460  - http
461"#;
462        let mut spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
463        spawner.enforce_tool_allowlist(&mut spec);
464
465        let tool_names: Vec<_> = spec
466            .tools
467            .as_ref()
468            .unwrap()
469            .iter()
470            .map(|t| t.name().to_string())
471            .collect();
472        assert_eq!(tool_names, vec!["echo", "calculator"]);
473    }
474
475    #[test]
476    fn test_with_template_plain_string() {
477        let spawner =
478            AgentSpawner::new().with_template("basic", "name: {{ name }}\nsystem_prompt: hi");
479        let tpl = spawner.templates().get("basic").unwrap();
480        assert_eq!(tpl.content, "name: {{ name }}\nsystem_prompt: hi");
481        assert!(tpl.description.is_none());
482        assert!(tpl.variables.is_none());
483    }
484
485    #[test]
486    fn test_with_templates_resolved() {
487        let mut templates = HashMap::new();
488        templates.insert(
489            "base".to_string(),
490            ResolvedTemplate {
491                content: "name: {{ name }}".to_string(),
492                description: Some("Test template".to_string()),
493                variables: Some({
494                    let mut v = HashMap::new();
495                    v.insert("role".to_string(), "occupation".to_string());
496                    v
497                }),
498            },
499        );
500        let spawner = AgentSpawner::new().with_templates(templates);
501        let tpl = spawner.templates().get("base").unwrap();
502        assert_eq!(tpl.description.as_deref(), Some("Test template"));
503        assert_eq!(
504            tpl.variables.as_ref().unwrap().get("role").unwrap(),
505            "occupation"
506        );
507    }
508}