ai_agents_runtime/spawner/
tools.rs1use std::sync::Arc;
4
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10use ai_agents_core::{ChatMessage, LLMProvider, Tool, ToolResult};
11use ai_agents_llm::LLMRegistry;
12use ai_agents_observability::{ObservationPurpose, with_observation_purpose};
13use ai_agents_tools::generate_schema;
14
15use super::registry::AgentRegistry;
16use super::spawner::AgentSpawner;
17use crate::turn_context::current_turn_actor_context;
18
19pub struct GenerateAgentTool {
27 spawner: Arc<AgentSpawner>,
28 registry: Arc<AgentRegistry>,
29 llm: Arc<LLMRegistry>,
30 enriched_description: String,
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
35#[allow(dead_code)]
36struct GenerateAgentInput {
37 description: String,
39 name: String,
41 #[serde(default)]
43 template: Option<String>,
44}
45
46impl GenerateAgentTool {
47 pub fn new(
48 spawner: Arc<AgentSpawner>,
49 registry: Arc<AgentRegistry>,
50 llm: Arc<LLMRegistry>,
51 ) -> Self {
52 let enriched_description = Self::build_description(&spawner);
53 Self {
54 spawner,
55 registry,
56 llm,
57 enriched_description,
58 }
59 }
60
61 fn build_description(spawner: &AgentSpawner) -> String {
63 let mut desc = String::from(
64 "Generate and spawn a new AI agent from a description. \
65 Provide a natural language description of the agent's \
66 personality, capabilities, and purpose.",
67 );
68
69 let templates = spawner.templates();
70 if templates.is_empty() {
71 return desc;
72 }
73
74 desc.push_str("\n\nAvailable templates (pass name as \"template\" field):");
75 for (name, tpl) in templates {
76 desc.push_str("\n ");
77 desc.push_str(name);
78 if let Some(ref d) = tpl.description {
79 desc.push_str(": ");
80 desc.push_str(d);
81 }
82 if let Some(ref vars) = tpl.variables {
83 for (var_name, var_desc) in vars {
84 desc.push_str("\n - ");
85 desc.push_str(var_name);
86 desc.push_str(": ");
87 desc.push_str(var_desc);
88 }
89 }
90 }
91
92 desc.push_str(
93 "\n\nWhen using a template, pass its variables as additional fields \
94 alongside name and description.",
95 );
96
97 desc
98 }
99}
100
101#[async_trait]
102impl Tool for GenerateAgentTool {
103 fn id(&self) -> &str {
104 "spawn_agent"
105 }
106
107 fn name(&self) -> &str {
108 "Spawn Agent"
109 }
110
111 fn description(&self) -> &str {
112 &self.enriched_description
113 }
114
115 fn input_schema(&self) -> Value {
116 generate_schema::<GenerateAgentInput>()
117 }
118
119 async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
120 let description = match args.get("description").and_then(|v| v.as_str()) {
121 Some(d) => d,
122 None => return ToolResult::error("missing required field: description"),
123 };
124 let name = match args.get("name").and_then(|v| v.as_str()) {
125 Some(n) => n,
126 None => return ToolResult::error("missing required field: name"),
127 };
128 let template = args.get("template").and_then(|v| v.as_str());
129
130 if let Some(tpl_name) = template {
133 let mut vars = std::collections::HashMap::new();
134 vars.insert("name".to_string(), name.to_string());
135 vars.insert("description".to_string(), description.to_string());
136
137 if let Some(obj) = args.as_object() {
139 for (k, v) in obj {
140 if k == "description" || k == "name" || k == "template" {
141 continue;
142 }
143 if let Some(s) = v.as_str() {
144 vars.insert(k.clone(), s.to_string());
145 }
146 }
147 }
148
149 return match self.spawner.spawn_from_template(tpl_name, vars).await {
150 Ok(agent) => {
151 let id = agent.id.clone();
152 match self.registry.register(agent).await {
153 Ok(()) => ToolResult::ok(
154 json!({"id": id, "source": "template", "template": tpl_name})
155 .to_string(),
156 ),
157 Err(e) => ToolResult::error(format!("registry error: {}", e)),
158 }
159 }
160 Err(e) => ToolResult::error(format!("template spawn failed: {}", e)),
161 };
162 }
163
164 let llm: Arc<dyn LLMProvider> = match self.llm.router() {
167 Ok(l) => l,
168 Err(_) => match self.llm.default() {
169 Ok(l) => l,
170 Err(e) => return ToolResult::error(format!("no LLM available: {}", e)),
171 },
172 };
173
174 let prompt = build_generation_prompt(name, description);
175 let messages = vec![ChatMessage::user(prompt)];
176
177 let yaml = match with_observation_purpose(
178 ObservationPurpose::OrchestrationRouting,
179 llm.complete(&messages, None),
180 )
181 .await
182 {
183 Ok(resp) => strip_code_fences(&resp.content),
184 Err(e) => return ToolResult::error(format!("LLM generation failed: {}", e)),
185 };
186
187 match self.spawner.spawn_from_yaml(&yaml).await {
189 Ok(agent) => {
190 let id = agent.id.clone();
191 return match self.registry.register(agent).await {
192 Ok(()) => {
193 ToolResult::ok(json!({"id": id, "source": "llm_generated"}).to_string())
194 }
195 Err(e) => ToolResult::error(format!("registry error: {}", e)),
196 };
197 }
198 Err(first_err) => {
199 let retry_prompt = format!(
201 "The YAML you generated was invalid:\n{}\n\nError: {}\n\n\
202 Please fix the YAML and return ONLY valid YAML with no markdown fences.",
203 yaml, first_err
204 );
205 let retry_messages = vec![
206 ChatMessage::user(build_generation_prompt(name, description)),
207 ChatMessage::assistant(&yaml),
208 ChatMessage::user(retry_prompt),
209 ];
210
211 let retry_yaml = match with_observation_purpose(
212 ObservationPurpose::OrchestrationRouting,
213 llm.complete(&retry_messages, None),
214 )
215 .await
216 {
217 Ok(resp) => strip_code_fences(&resp.content),
218 Err(e) => {
219 return ToolResult::error(format!(
220 "LLM retry failed: {} (original error: {})",
221 e, first_err
222 ));
223 }
224 };
225
226 match self.spawner.spawn_from_yaml(&retry_yaml).await {
227 Ok(agent) => {
228 let id = agent.id.clone();
229 match self.registry.register(agent).await {
230 Ok(()) => ToolResult::ok(
231 json!({"id": id, "source": "llm_generated", "retried": true})
232 .to_string(),
233 ),
234 Err(e) => ToolResult::error(format!("registry error: {}", e)),
235 }
236 }
237 Err(e) => ToolResult::error(format!(
238 "spawn failed after retry: {} (original: {})",
239 e, first_err
240 )),
241 }
242 }
243 }
244 }
245}
246
247fn build_generation_prompt(name: &str, description: &str) -> String {
249 format!(
250 "Generate a valid YAML agent specification.\n\n\
251 Required fields:\n\
252 - name: string (the agent's name)\n\
253 - system_prompt: string (detailed behavioral instructions)\n\n\
254 Optional fields: memory (type, max_messages, compress_threshold), \
255 reasoning (mode: auto|cot|react), disambiguation (enabled: true/false).\n\n\
256 Example:\n\
257 ```yaml\n\
258 name: Helper\n\
259 system_prompt: |\n\
260 You are a helpful assistant who answers concisely.\n\
261 memory:\n\
262 type: compacting\n\
263 max_messages: 100\n\
264 compress_threshold: 20\n\
265 ```\n\n\
266 Now generate a spec for:\n\
267 Name: {}\n\
268 Description: {}\n\n\
269 Return ONLY the YAML content. No markdown fences, no commentary.",
270 name, description
271 )
272}
273
274fn strip_code_fences(text: &str) -> String {
276 let trimmed = text.trim();
277 let trimmed = trimmed
278 .strip_prefix("```yaml")
279 .or_else(|| trimmed.strip_prefix("```"))
280 .unwrap_or(trimmed);
281 let trimmed = trimmed.strip_suffix("```").unwrap_or(trimmed);
282 trimmed.trim().to_string()
283}
284
285pub struct SendMessageTool {
291 registry: Arc<AgentRegistry>,
292 sender_id: String,
294}
295
296#[derive(Debug, Deserialize, JsonSchema)]
297#[allow(dead_code)]
298struct SendMessageInput {
299 to: String,
301 message: String,
303}
304
305impl SendMessageTool {
306 pub fn new(registry: Arc<AgentRegistry>, sender_id: impl Into<String>) -> Self {
307 Self {
308 registry,
309 sender_id: sender_id.into(),
310 }
311 }
312}
313
314#[async_trait]
315impl Tool for SendMessageTool {
316 fn id(&self) -> &str {
317 "send_agent_message"
318 }
319
320 fn name(&self) -> &str {
321 "Send Agent Message"
322 }
323
324 fn description(&self) -> &str {
325 "Send a message to another registered agent and receive its response."
326 }
327
328 fn input_schema(&self) -> Value {
329 generate_schema::<SendMessageInput>()
330 }
331
332 async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
333 let to = match args.get("to").and_then(|v| v.as_str()) {
334 Some(t) => t,
335 None => return ToolResult::error("missing required field: to"),
336 };
337 let message = match args.get("message").and_then(|v| v.as_str()) {
338 Some(m) => m,
339 None => return ToolResult::error("missing required field: message"),
340 };
341
342 let actor_context = current_turn_actor_context()
343 .unwrap_or_default()
344 .for_sender(self.sender_id.clone());
345 match self
346 .registry
347 .send_with_actor_context(&self.sender_id, to, message, actor_context)
348 .await
349 {
350 Ok(response) => {
351 ToolResult::ok(json!({"from": to, "response": response.content}).to_string())
352 }
353 Err(e) => ToolResult::error(format!("send failed: {}", e)),
354 }
355 }
356}
357
358pub struct ListAgentsTool {
364 registry: Arc<AgentRegistry>,
365}
366
367#[derive(Debug, Deserialize, JsonSchema)]
368#[allow(dead_code)]
369struct ListAgentsInput {}
370
371impl ListAgentsTool {
372 pub fn new(registry: Arc<AgentRegistry>) -> Self {
373 Self { registry }
374 }
375}
376
377#[async_trait]
378impl Tool for ListAgentsTool {
379 fn id(&self) -> &str {
380 "list_agents"
381 }
382
383 fn name(&self) -> &str {
384 "List Agents"
385 }
386
387 fn description(&self) -> &str {
388 "List all currently registered agents with their IDs and names."
389 }
390
391 fn input_schema(&self) -> Value {
392 generate_schema::<ListAgentsInput>()
393 }
394
395 async fn execute(
396 &self,
397 _args: Value,
398 _ctx: ai_agents_core::ToolExecutionContext,
399 ) -> ToolResult {
400 let agents = self.registry.list();
401 match serde_json::to_string(&agents) {
402 Ok(json) => ToolResult::ok(json),
403 Err(e) => ToolResult::error(format!("serialization error: {}", e)),
404 }
405 }
406}
407
408pub struct RemoveAgentTool {
414 registry: Arc<AgentRegistry>,
415}
416
417#[derive(Debug, Deserialize, JsonSchema)]
418#[allow(dead_code)]
419struct RemoveAgentInput {
420 id: String,
422}
423
424impl RemoveAgentTool {
425 pub fn new(registry: Arc<AgentRegistry>) -> Self {
426 Self { registry }
427 }
428}
429
430#[async_trait]
431impl Tool for RemoveAgentTool {
432 fn id(&self) -> &str {
433 "remove_agent"
434 }
435
436 fn name(&self) -> &str {
437 "Remove Agent"
438 }
439
440 fn description(&self) -> &str {
441 "Remove a registered agent by its ID."
442 }
443
444 fn input_schema(&self) -> Value {
445 generate_schema::<RemoveAgentInput>()
446 }
447
448 async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
449 let id = match args.get("id").and_then(|v| v.as_str()) {
450 Some(i) => i,
451 None => return ToolResult::error("missing required field: id"),
452 };
453
454 match self.registry.remove(id).await {
455 Some(removed) => ToolResult::ok(json!({"removed": true, "id": removed.id}).to_string()),
456 None => ToolResult::error(format!("agent not found: {}", id)),
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::super::spawner::ResolvedTemplate;
464 use super::*;
465 use std::collections::HashMap;
466
467 #[test]
468 fn test_strip_code_fences_yaml() {
469 let input = "```yaml\nname: Test\nsystem_prompt: hi\n```";
470 assert_eq!(strip_code_fences(input), "name: Test\nsystem_prompt: hi");
471 }
472
473 #[test]
474 fn test_strip_code_fences_bare() {
475 let input = "```\nname: Test\n```";
476 assert_eq!(strip_code_fences(input), "name: Test");
477 }
478
479 #[test]
480 fn test_strip_code_fences_none() {
481 let input = "name: Test\nsystem_prompt: hi";
482 assert_eq!(strip_code_fences(input), input);
483 }
484
485 #[test]
486 fn test_build_generation_prompt_contains_name() {
487 let prompt = build_generation_prompt("Gormund", "A gruff blacksmith");
488 assert!(prompt.contains("Gormund"));
489 assert!(prompt.contains("gruff blacksmith"));
490 }
491
492 #[test]
493 fn test_tool_ids_are_unique() {
494 let ids = [
495 "spawn_agent",
496 "send_agent_message",
497 "list_agents",
498 "remove_agent",
499 ];
500 let unique: std::collections::HashSet<_> = ids.iter().collect();
501 assert_eq!(unique.len(), ids.len());
502 }
503
504 #[test]
505 fn test_build_description_no_templates() {
506 let spawner = AgentSpawner::new();
507 let desc = GenerateAgentTool::build_description(&spawner);
508 assert!(desc.contains("Generate and spawn"));
509 assert!(!desc.contains("Available templates"));
510 }
511
512 #[test]
513 fn test_build_description_with_templates() {
514 let mut templates = HashMap::new();
515 templates.insert(
516 "npc_base".to_string(),
517 ResolvedTemplate {
518 content: "name: test".to_string(),
519 description: Some("General-purpose NPC".to_string()),
520 variables: Some({
521 let mut v = HashMap::new();
522 v.insert("role".to_string(), "NPC occupation".to_string());
523 v.insert(
524 "personality".to_string(),
525 "Personality description".to_string(),
526 );
527 v
528 }),
529 },
530 );
531 let spawner = AgentSpawner::new().with_templates(templates);
532 let desc = GenerateAgentTool::build_description(&spawner);
533 assert!(desc.contains("Available templates"));
534 assert!(desc.contains("npc_base"));
535 assert!(desc.contains("General-purpose NPC"));
536 assert!(desc.contains("role"));
537 assert!(desc.contains("NPC occupation"));
538 assert!(desc.contains("personality"));
539 }
540
541 #[test]
542 fn test_build_description_template_no_metadata() {
543 let mut templates = HashMap::new();
544 templates.insert(
545 "bare".to_string(),
546 ResolvedTemplate {
547 content: "name: test".to_string(),
548 description: None,
549 variables: None,
550 },
551 );
552 let spawner = AgentSpawner::new().with_templates(templates);
553 let desc = GenerateAgentTool::build_description(&spawner);
554 assert!(desc.contains("Available templates"));
555 assert!(desc.contains("bare"));
556 assert!(!desc.contains("NPC"));
558 }
559
560 #[test]
561 fn test_spawn_agent_schema_has_required_fields() {
562 let schema = generate_schema::<GenerateAgentInput>();
563 let props = schema.get("properties").expect("should have properties");
564 assert!(props.get("description").is_some());
565 assert!(props.get("name").is_some());
566 assert!(props.get("template").is_some());
567 let required = schema.get("required").expect("should have required");
568 let req_arr: Vec<&str> = required
569 .as_array()
570 .unwrap()
571 .iter()
572 .map(|v| v.as_str().unwrap())
573 .collect();
574 assert!(req_arr.contains(&"description"));
575 assert!(req_arr.contains(&"name"));
576 assert!(!req_arr.contains(&"template"));
578 }
579
580 #[test]
581 fn test_send_agent_message_schema_has_required_fields() {
582 let schema = generate_schema::<SendMessageInput>();
583 let props = schema.get("properties").expect("should have properties");
584 assert!(props.get("to").is_some());
585 assert!(props.get("message").is_some());
586 }
587
588 #[test]
589 fn test_remove_agent_schema_has_id() {
590 let schema = generate_schema::<RemoveAgentInput>();
591 let props = schema.get("properties").expect("should have properties");
592 assert!(props.get("id").is_some());
593 }
594
595 #[test]
596 fn test_list_agents_schema_is_object() {
597 let schema = generate_schema::<ListAgentsInput>();
598 assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
599 }
600}