ai_agents_runtime/spawner/
spawner.rs1use 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#[derive(Debug, Clone)]
23pub struct ResolvedTemplate {
24 pub content: String,
26 pub description: Option<String>,
28 pub variables: Option<HashMap<String, String>>,
30}
31
32impl ResolvedTemplate {
33 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
43pub struct SpawnedAgent {
45 pub id: String,
47 pub agent: Arc<RuntimeAgent>,
49 pub spec: AgentSpec,
51 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
64pub struct AgentSpawner {
66 llm_registry: Option<LLMRegistry>,
68
69 llm_registry_observed: bool,
71
72 storage: Option<Arc<dyn AgentStorage>>,
74
75 shared_context: HashMap<String, serde_json::Value>,
77
78 max_agents: Option<usize>,
80
81 name_prefix: Option<String>,
83
84 templates: HashMap<String, ResolvedTemplate>,
86
87 allowed_tools: Option<Vec<String>>,
89
90 resource_locks: Option<ToolResourceLocks>,
92
93 observability_manager: Option<Arc<ObservabilityManager>>,
95
96 counter: AtomicU32,
98
99 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 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 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 pub fn with_shared_storage(mut self, storage: Arc<dyn AgentStorage>) -> Self {
137 self.storage = Some(storage);
138 self
139 }
140
141 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 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 pub fn with_max_agents(mut self, max: usize) -> Self {
155 self.max_agents = Some(max);
156 self
157 }
158
159 pub fn with_name_prefix(mut self, prefix: impl Into<String>) -> Self {
161 self.name_prefix = Some(prefix.into());
162 self
163 }
164
165 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 pub fn with_templates(mut self, templates: HashMap<String, ResolvedTemplate>) -> Self {
178 self.templates.extend(templates);
179 self
180 }
181
182 pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
184 self.allowed_tools = Some(tools);
185 self
186 }
187
188 pub fn with_resource_locks(mut self, locks: ToolResourceLocks) -> Self {
190 self.resource_locks = Some(locks);
191 self
192 }
193
194 pub fn with_observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
196 self.observability_manager = Some(manager);
197 self
198 }
199
200 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 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 pub async fn spawn_with_id(&self, id: String, spec: AgentSpec) -> Result<SpawnedAgent> {
216 self.spawn_inner(id, spec).await
217 }
218
219 async fn spawn_inner(&self, agent_id: String, spec: AgentSpec) -> Result<SpawnedAgent> {
221 self.check_spawn_limit()?;
222
223 let mut builder = AgentBuilder::from_spec(spec.clone());
225
226 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 if !spec.llms.is_empty() {
237 builder = builder.auto_configure_llms()?;
238 }
239
240 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 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 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 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 pub fn spawned_count(&self) -> u32 {
298 self.agent_count.load(Ordering::Relaxed)
299 }
300
301 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 pub fn llm_registry(&self) -> Option<&LLMRegistry> {
311 self.llm_registry.as_ref()
312 }
313
314 pub fn shared_storage(&self) -> Option<&Arc<dyn AgentStorage>> {
316 self.storage.as_ref()
317 }
318
319 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 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 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 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 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}