Skip to main content

ai_agents_runtime/orchestration/
tools.rs

1use std::sync::Arc;
2use std::sync::atomic::AtomicUsize;
3
4use async_trait::async_trait;
5use schemars::JsonSchema;
6use serde::Deserialize;
7use serde_json::{Value, json};
8
9use ai_agents_core::{Tool, ToolResult};
10use ai_agents_llm::LLMRegistry;
11use ai_agents_tools::generate_schema;
12
13use super::types::RoutingMethod;
14use crate::spawner::AgentRegistry;
15use crate::turn_context::{current_turn_actor_context, scope_actor_context};
16
17//
18// RouteToAgentTool
19//
20
21pub struct RouteToAgentTool {
22    registry: Arc<AgentRegistry>,
23    llm: Arc<LLMRegistry>,
24    counter: AtomicUsize,
25}
26
27#[derive(Debug, Deserialize, JsonSchema)]
28#[allow(dead_code)]
29struct RouteToAgentInput {
30    /// The message to route to the best-matched agent.
31    input: String,
32    /// Agent IDs to consider for routing.
33    candidates: Vec<String>,
34    /// Routing method: "llm" or "round_robin". Defaults to "llm".
35    method: Option<String>,
36}
37
38impl RouteToAgentTool {
39    pub fn new(registry: Arc<AgentRegistry>, llm: Arc<LLMRegistry>) -> Self {
40        Self {
41            registry,
42            llm,
43            counter: AtomicUsize::new(0),
44        }
45    }
46}
47
48#[async_trait]
49impl Tool for RouteToAgentTool {
50    fn id(&self) -> &str {
51        "route_to_agent"
52    }
53
54    fn name(&self) -> &str {
55        "Route to Agent"
56    }
57
58    fn description(&self) -> &str {
59        "Send input to the best-matched agent from a set of candidates."
60    }
61
62    fn input_schema(&self) -> Value {
63        generate_schema::<RouteToAgentInput>()
64    }
65
66    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
67        let input = match args.get("input").and_then(|v| v.as_str()) {
68            Some(s) => s,
69            None => return ToolResult::error("missing required field: input"),
70        };
71        let candidates: Vec<String> = match args.get("candidates").and_then(|v| v.as_array()) {
72            Some(arr) => arr
73                .iter()
74                .filter_map(|v| v.as_str().map(String::from))
75                .collect(),
76            None => return ToolResult::error("missing required field: candidates"),
77        };
78        let method = match args.get("method").and_then(|v| v.as_str()) {
79            Some("round_robin") => RoutingMethod::RoundRobin,
80            _ => RoutingMethod::Llm,
81        };
82
83        let llm = match self.llm.get("router") {
84            Ok(p) => p,
85            Err(_) => return ToolResult::error("no router LLM configured"),
86        };
87
88        let route_future = super::route(
89            &self.registry,
90            llm.as_ref(),
91            input,
92            &candidates,
93            method,
94            Some(&self.counter),
95        );
96        let route_result = if let Some(context) = current_turn_actor_context() {
97            scope_actor_context(context, route_future).await
98        } else {
99            route_future.await
100        };
101
102        match route_result {
103            Ok(result) => ToolResult::ok(
104                json!({
105                    "selected_agent": result.selected_agent,
106                    "response": result.response.content,
107                    "reason": result.reason,
108                })
109                .to_string(),
110            ),
111            Err(e) => ToolResult::error(format!("routing failed: {}", e)),
112        }
113    }
114}
115
116//
117// PipelineProcessTool
118//
119
120pub struct PipelineProcessTool {
121    registry: Arc<AgentRegistry>,
122}
123
124#[derive(Debug, Deserialize, JsonSchema)]
125#[allow(dead_code)]
126struct PipelineProcessInput {
127    /// Initial input for the first pipeline stage.
128    input: String,
129    /// Ordered agent IDs forming the pipeline stages.
130    stages: Vec<String>,
131    /// Optional per-stage input templates with {{ previous_output }} and {{ original_input }} variables.
132    stage_inputs: Option<Vec<Option<String>>>,
133}
134
135impl PipelineProcessTool {
136    pub fn new(registry: Arc<AgentRegistry>) -> Self {
137        Self { registry }
138    }
139}
140
141#[async_trait]
142impl Tool for PipelineProcessTool {
143    fn id(&self) -> &str {
144        "pipeline_process"
145    }
146
147    fn name(&self) -> &str {
148        "Pipeline Process"
149    }
150
151    fn description(&self) -> &str {
152        "Chain agents sequentially. Each agent processes the previous output."
153    }
154
155    fn input_schema(&self) -> Value {
156        generate_schema::<PipelineProcessInput>()
157    }
158
159    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
160        let input = match args.get("input").and_then(|v| v.as_str()) {
161            Some(s) => s,
162            None => return ToolResult::error("missing required field: input"),
163        };
164        let stage_ids: Vec<String> = match args.get("stages").and_then(|v| v.as_array()) {
165            Some(arr) => arr
166                .iter()
167                .filter_map(|v| v.as_str().map(String::from))
168                .collect(),
169            None => return ToolResult::error("missing required field: stages"),
170        };
171
172        let stage_inputs: Vec<Option<String>> = args
173            .get("stage_inputs")
174            .and_then(|v| v.as_array())
175            .map(|arr| arr.iter().map(|v| v.as_str().map(String::from)).collect())
176            .unwrap_or_default();
177
178        let pipeline_stages: Vec<super::types::PipelineStage> = stage_ids
179            .into_iter()
180            .enumerate()
181            .map(|(i, id)| {
182                let mut stage = super::types::PipelineStage::id(id);
183                if let Some(tmpl) = stage_inputs.get(i).and_then(|o| o.clone()) {
184                    stage = stage.with_input(tmpl);
185                }
186                stage
187            })
188            .collect();
189
190        let pipeline_future =
191            super::pipeline(&self.registry, input, &pipeline_stages, None, None, None);
192        let pipeline_result = if let Some(context) = current_turn_actor_context() {
193            scope_actor_context(context, pipeline_future).await
194        } else {
195            pipeline_future.await
196        };
197
198        match pipeline_result {
199            Ok(result) => ToolResult::ok(
200                json!({
201                    "response": result.response.content,
202                    "stages": result.stage_outputs.iter().map(|s| {
203                        json!({
204                            "agent_id": s.agent_id,
205                            "output": s.output,
206                            "duration_ms": s.duration_ms,
207                            "skipped": s.skipped,
208                        })
209                    }).collect::<Vec<_>>(),
210                })
211                .to_string(),
212            ),
213            Err(e) => ToolResult::error(format!("pipeline failed: {}", e)),
214        }
215    }
216}
217
218//
219// ConcurrentAskTool
220//
221
222pub struct ConcurrentAskTool {
223    registry: Arc<AgentRegistry>,
224    llm: Arc<LLMRegistry>,
225}
226
227#[derive(Debug, Deserialize, JsonSchema)]
228#[allow(dead_code)]
229struct ConcurrentAskInput {
230    /// The question to ask all agents in parallel.
231    question: String,
232    /// Agent IDs to query concurrently.
233    agents: Vec<String>,
234    /// Aggregation strategy: "all", "llm_synthesis", "first_wins", or "voting". Defaults to "all".
235    aggregation: Option<String>,
236}
237
238impl ConcurrentAskTool {
239    pub fn new(registry: Arc<AgentRegistry>, llm: Arc<LLMRegistry>) -> Self {
240        Self { registry, llm }
241    }
242}
243
244#[async_trait]
245impl Tool for ConcurrentAskTool {
246    fn id(&self) -> &str {
247        "concurrent_ask"
248    }
249
250    fn name(&self) -> &str {
251        "Concurrent Ask"
252    }
253
254    fn description(&self) -> &str {
255        "Ask multiple agents the same question in parallel and aggregate results."
256    }
257
258    fn input_schema(&self) -> Value {
259        generate_schema::<ConcurrentAskInput>()
260    }
261
262    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
263        let question = match args.get("question").and_then(|v| v.as_str()) {
264            Some(s) => s,
265            None => return ToolResult::error("missing required field: question"),
266        };
267        let agent_ids: Vec<String> = match args.get("agents").and_then(|v| v.as_array()) {
268            Some(arr) => arr
269                .iter()
270                .filter_map(|v| v.as_str().map(String::from))
271                .collect(),
272            None => return ToolResult::error("missing required field: agents"),
273        };
274        let strategy_str = args
275            .get("aggregation")
276            .and_then(|v| v.as_str())
277            .unwrap_or("all");
278
279        let strategy = match strategy_str {
280            "llm_synthesis" => ai_agents_state::AggregationStrategy::LlmSynthesis,
281            "first_wins" => ai_agents_state::AggregationStrategy::FirstWins,
282            "voting" => ai_agents_state::AggregationStrategy::Voting,
283            _ => ai_agents_state::AggregationStrategy::All,
284        };
285
286        let agents: Vec<ai_agents_state::ConcurrentAgentRef> = agent_ids
287            .iter()
288            .map(|id| ai_agents_state::ConcurrentAgentRef::Id(id.clone()))
289            .collect();
290
291        let aggregation = ai_agents_state::AggregationConfig {
292            strategy,
293            synthesizer_llm: Some("router".to_string()),
294            synthesizer_prompt: None,
295            vote: None,
296        };
297
298        let llm = self.llm.get("router").ok();
299
300        let concurrent_future = super::concurrent(
301            &self.registry,
302            question,
303            &agents,
304            &aggregation,
305            llm.as_deref(),
306            None,
307            None,
308            ai_agents_state::PartialFailureAction::ProceedWithAvailable,
309            None,
310        );
311        let concurrent_result = if let Some(context) = current_turn_actor_context() {
312            scope_actor_context(context, concurrent_future).await
313        } else {
314            concurrent_future.await
315        };
316
317        match concurrent_result {
318            Ok(result) => ToolResult::ok(
319                json!({
320                    "response": result.response.content,
321                    "agent_results": result.agent_results.iter().map(|ar| {
322                        json!({
323                            "agent_id": ar.agent_id,
324                            "response": ar.response.as_ref().map(|r| r.content.as_str()),
325                            "success": ar.success,
326                        })
327                    }).collect::<Vec<_>>(),
328                })
329                .to_string(),
330            ),
331            Err(e) => ToolResult::error(format!("concurrent ask failed: {}", e)),
332        }
333    }
334}
335
336//
337// GroupDiscussionTool
338//
339
340pub struct GroupDiscussionTool {
341    registry: Arc<AgentRegistry>,
342    llm: Arc<LLMRegistry>,
343}
344
345#[derive(Debug, Deserialize, JsonSchema)]
346#[allow(dead_code)]
347struct GroupDiscussionInput {
348    /// The discussion topic.
349    topic: String,
350    /// Agent IDs participating in the discussion.
351    participants: Vec<String>,
352    /// Discussion style: "brainstorm", "debate", or "consensus". Defaults to "brainstorm".
353    style: Option<String>,
354    /// Maximum conversation rounds. Defaults to 3.
355    max_rounds: Option<u32>,
356}
357
358impl GroupDiscussionTool {
359    pub fn new(registry: Arc<AgentRegistry>, llm: Arc<LLMRegistry>) -> Self {
360        Self { registry, llm }
361    }
362}
363
364#[async_trait]
365impl Tool for GroupDiscussionTool {
366    fn id(&self) -> &str {
367        "group_discussion"
368    }
369
370    fn name(&self) -> &str {
371        "Group Discussion"
372    }
373
374    fn description(&self) -> &str {
375        "Run a multi-agent conversation on a topic with configurable style."
376    }
377
378    fn input_schema(&self) -> Value {
379        generate_schema::<GroupDiscussionInput>()
380    }
381
382    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
383        let topic = match args.get("topic").and_then(|v| v.as_str()) {
384            Some(s) => s,
385            None => return ToolResult::error("missing required field: topic"),
386        };
387        let participant_ids: Vec<String> = match args.get("participants").and_then(|v| v.as_array())
388        {
389            Some(arr) => arr
390                .iter()
391                .filter_map(|v| v.as_str().map(String::from))
392                .collect(),
393            None => return ToolResult::error("missing required field: participants"),
394        };
395        let style = match args.get("style").and_then(|v| v.as_str()) {
396            Some("debate") => ai_agents_state::ChatStyle::Debate,
397            Some("consensus") => ai_agents_state::ChatStyle::Consensus,
398            _ => ai_agents_state::ChatStyle::Brainstorm,
399        };
400        let max_rounds = args
401            .get("max_rounds")
402            .and_then(|v| v.as_u64())
403            .map(|v| v as u32)
404            .unwrap_or(3);
405
406        let participants: Vec<ai_agents_state::ChatParticipant> = participant_ids
407            .iter()
408            .map(|id| ai_agents_state::ChatParticipant {
409                id: id.clone(),
410                role: None,
411            })
412            .collect();
413
414        let config = ai_agents_state::GroupChatStateConfig {
415            participants,
416            style,
417            max_rounds,
418            manager: None,
419            termination: ai_agents_state::TerminationConfig {
420                method: ai_agents_state::TerminationMethod::MaxRounds,
421                max_stall_rounds: 2,
422            },
423            debate: None,
424            maker_checker: None,
425            timeout_ms: None,
426            input: None,
427            context_mode: None,
428        };
429
430        let llm = self.llm.get("router").ok();
431
432        let group_future = super::group_chat(&self.registry, topic, &config, llm.as_deref(), None);
433        let group_result = if let Some(context) = current_turn_actor_context() {
434            scope_actor_context(context, group_future).await
435        } else {
436            group_future.await
437        };
438
439        match group_result {
440            Ok(result) => ToolResult::ok(
441                json!({
442                    "conclusion": result.response.content,
443                    "rounds": result.rounds_completed,
444                    "termination": result.termination_reason,
445                    "transcript": result.transcript.iter().map(|t| {
446                        json!({
447                            "speaker": t.speaker,
448                            "round": t.round,
449                            "content": t.content,
450                        })
451                    }).collect::<Vec<_>>(),
452                })
453                .to_string(),
454            ),
455            Err(e) => ToolResult::error(format!("group discussion failed: {}", e)),
456        }
457    }
458}
459
460//
461// HandoffConversationTool
462//
463
464pub struct HandoffConversationTool {
465    registry: Arc<AgentRegistry>,
466    llm: Arc<LLMRegistry>,
467}
468
469#[derive(Debug, Deserialize, JsonSchema)]
470#[allow(dead_code)]
471struct HandoffConversationInput {
472    /// The initial message to start the conversation.
473    input: String,
474    /// Starting agent ID.
475    initial_agent: String,
476    /// Agent IDs that can receive handoffs.
477    available_agents: Vec<String>,
478    /// Maximum number of handoffs allowed. Defaults to 5.
479    max_handoffs: Option<u32>,
480}
481
482impl HandoffConversationTool {
483    pub fn new(registry: Arc<AgentRegistry>, llm: Arc<LLMRegistry>) -> Self {
484        Self { registry, llm }
485    }
486}
487
488#[async_trait]
489impl Tool for HandoffConversationTool {
490    fn id(&self) -> &str {
491        "handoff_conversation"
492    }
493
494    fn name(&self) -> &str {
495        "Handoff Conversation"
496    }
497
498    fn description(&self) -> &str {
499        "Start a conversation with one agent and allow dynamic handoffs to other agents."
500    }
501
502    fn input_schema(&self) -> Value {
503        generate_schema::<HandoffConversationInput>()
504    }
505
506    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
507        let input = match args.get("input").and_then(|v| v.as_str()) {
508            Some(s) => s,
509            None => return ToolResult::error("missing required field: input"),
510        };
511        let initial_agent = match args.get("initial_agent").and_then(|v| v.as_str()) {
512            Some(s) => s,
513            None => return ToolResult::error("missing required field: initial_agent"),
514        };
515        let available_agents: Vec<String> =
516            match args.get("available_agents").and_then(|v| v.as_array()) {
517                Some(arr) => arr
518                    .iter()
519                    .filter_map(|v| v.as_str().map(String::from))
520                    .collect(),
521                None => return ToolResult::error("missing required field: available_agents"),
522            };
523        let max_handoffs = args
524            .get("max_handoffs")
525            .and_then(|v| v.as_u64())
526            .map(|v| v as u32)
527            .unwrap_or(5);
528
529        let llm = match self.llm.get("router") {
530            Ok(p) => p,
531            Err(_) => return ToolResult::error("no router LLM configured"),
532        };
533
534        let handoff_future = super::handoff(
535            &self.registry,
536            input,
537            initial_agent,
538            &available_agents,
539            max_handoffs,
540            llm.as_ref(),
541            None,
542        );
543        let handoff_result = if let Some(context) = current_turn_actor_context() {
544            scope_actor_context(context, handoff_future).await
545        } else {
546            handoff_future.await
547        };
548
549        match handoff_result {
550            Ok(result) => ToolResult::ok(
551                json!({
552                    "response": result.response.content,
553                    "final_agent": result.final_agent,
554                    "handoffs": result.handoff_chain.iter().map(|h| {
555                        json!({
556                            "from": h.from_agent,
557                            "to": h.to_agent,
558                            "reason": h.reason,
559                        })
560                    }).collect::<Vec<_>>(),
561                })
562                .to_string(),
563            ),
564            Err(e) => ToolResult::error(format!("handoff failed: {}", e)),
565        }
566    }
567}
568
569/// Build the list of orchestration tools enabled by the config.
570pub fn configure_orchestration_tools(
571    config: &crate::spec::OrchestrationToolsConfig,
572    registry: Arc<AgentRegistry>,
573    llm: Arc<LLMRegistry>,
574) -> Vec<Arc<dyn Tool>> {
575    let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
576
577    if config.includes("route_to_agent") {
578        tools.push(Arc::new(RouteToAgentTool::new(
579            Arc::clone(&registry),
580            Arc::clone(&llm),
581        )));
582    }
583    if config.includes("pipeline_process") {
584        tools.push(Arc::new(PipelineProcessTool::new(Arc::clone(&registry))));
585    }
586    if config.includes("concurrent_ask") {
587        tools.push(Arc::new(ConcurrentAskTool::new(
588            Arc::clone(&registry),
589            Arc::clone(&llm),
590        )));
591    }
592    if config.includes("group_discussion") {
593        tools.push(Arc::new(GroupDiscussionTool::new(
594            Arc::clone(&registry),
595            Arc::clone(&llm),
596        )));
597    }
598    if config.includes("handoff_conversation") {
599        tools.push(Arc::new(HandoffConversationTool::new(
600            Arc::clone(&registry),
601            Arc::clone(&llm),
602        )));
603    }
604
605    tools
606}