Skip to main content

ai_agents_runtime/spawner/
tools.rs

1//! Built-in tools for inter-agent messaging and dynamic agent management.
2
3use 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
19//
20// GenerateAgentTool
21//
22
23/// Tool that lets a parent agent generate and spawn a new agent.
24///
25/// The tool description is built dynamically from template metadata so the LLM can discover available templates and their variables automatically.
26pub struct GenerateAgentTool {
27    spawner: Arc<AgentSpawner>,
28    registry: Arc<AgentRegistry>,
29    llm: Arc<LLMRegistry>,
30    /// Pre-built description including available templates and their variables.
31    enriched_description: String,
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
35#[allow(dead_code)]
36struct GenerateAgentInput {
37    /// Natural language description of the agent to create.
38    description: String,
39    /// Agent name / ID.
40    name: String,
41    /// Optional: named template to render instead of LLM generation.
42    #[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    /// Build tool description from template metadata (description + variables).
62    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        //
131        // Template path
132        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            // Forward any extra top-level string fields as template variables.
138            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        //
165        // LLM generation
166        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        // First attempt: parse and spawn.
188        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                // Retry once with an error-correction prompt.
200                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
247/// Build the YAML-generation prompt sent to the LLM.
248fn 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
274/// Strip optional markdown code fences from LLM output.
275fn 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
285//
286// SendMessageTool
287//
288
289/// Tool that sends a message from the owning agent to another registered agent.
290pub struct SendMessageTool {
291    registry: Arc<AgentRegistry>,
292    /// ID of the agent that owns this tool (the sender).
293    sender_id: String,
294}
295
296#[derive(Debug, Deserialize, JsonSchema)]
297#[allow(dead_code)]
298struct SendMessageInput {
299    /// Target agent ID.
300    to: String,
301    /// Message to send.
302    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
358//
359// ListAgentsTool
360//
361
362/// Tool that lists all agents currently registered in the registry.
363pub 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
408//
409// RemoveAgentTool
410//
411
412/// Tool that removes an agent from the registry by ID.
413pub struct RemoveAgentTool {
414    registry: Arc<AgentRegistry>,
415}
416
417#[derive(Debug, Deserialize, JsonSchema)]
418#[allow(dead_code)]
419struct RemoveAgentInput {
420    /// Agent ID to remove.
421    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        // No description or variables appended
557        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        // template is optional (Option<String>), should not be required
577        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}