Skip to main content

ares_llm/
coordinator.rs

1//! Generic Tool Coordinator for Multi-Turn Tool Calling
2//!
3//! This module provides a provider-agnostic `ToolCoordinator` that works with any
4//! `LLMClient` implementation. It handles the complete tool calling loop:
5//!
6//! 1. Send prompt with available tools to the LLM
7//! 2. If the model requests tool calls, execute them
8//! 3. Send tool results back to the model  
9//! 4. Repeat until completion or max iterations
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use ares_llm::coordinator::{ToolCoordinator, ToolCallingConfig};
15//! use ares_llm::Provider;
16//! use ares_tools::{Tools, Tool};
17//! use cordis::Context;
18//! use std::sync::Arc;
19//!
20//! let client = Provider::from_env()?.create_client().await?;
21//! let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
22//! let coordinator = ToolCoordinator::new(client, tools, ToolCallingConfig::default());
23//! let ctx = Context::new_root();
24//!
25//! let result = coordinator.execute(
26//!     Some("You are a helpful assistant."),
27//!     "What's 2 + 2?",
28//!     &ctx,
29//! ).await?;
30//!
31//! println!("Response: {}", result.content);
32//! println!("Tool calls made: {}", result.tool_calls.len());
33//! ```
34
35use crate::capabilities::{CapabilityRequirements, ModelCapabilities};
36use crate::client::{LLMClient, TokenUsage};
37#[cfg(test)]
38use ares_tools::Tool;
39use ares_tools::Tools;
40use ares_types::types::{ContentPart, Result, ToolCall};
41use crate::client::CacheControl;
42use cordis::Context;
43use futures::future::join_all;
44use serde::{Deserialize, Serialize};
45use std::sync::Arc;
46use std::time::{Duration, Instant};
47use tokio::time::timeout;
48
49use serde_json::Value;
50use std::collections::HashSet;
51use std::fmt;
52
53// Provider dispatch coordination (R42)
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum LoadBalanceStrategy {
58    #[default]
59    RoundRobin,
60    LeastLoaded,
61    Affinity,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct ProviderEndpoint {
66    pub id: String,
67    pub model: String,
68    pub capabilities: ModelCapabilities,
69    #[serde(default)]
70    pub in_flight_requests: u32,
71    #[serde(default)]
72    pub affinity_group: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CoordinatorConfig {
77    pub providers: Vec<ProviderEndpoint>,
78    #[serde(default)]
79    pub fallback_chain: Vec<String>,
80    #[serde(default)]
81    pub load_balance: LoadBalanceStrategy,
82    #[serde(default)]
83    pub requirements: CapabilityRequirements,
84    #[serde(default)]
85    pub affinity_key: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct RouteDecision {
90    pub provider_id: String,
91    pub model: String,
92    pub reason: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct DispatchPlan {
97    pub primary: RouteDecision,
98    pub fallbacks: Vec<RouteDecision>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct DispatchState {
103    pub round_robin_cursor: usize,
104    pub session_affinity: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum CoordinatorDispatchError {
109    NoAvailableProvider(String),
110    AllFailed(Vec<String>),
111    InvalidConfig(String),
112}
113
114impl fmt::Display for CoordinatorDispatchError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            Self::NoAvailableProvider(msg) => write!(f, "no available provider: {msg}"),
118            Self::AllFailed(errors) => write!(
119                f,
120                "all providers failed ({} attempts): {}",
121                errors.len(),
122                errors.join("; ")
123            ),
124            Self::InvalidConfig(msg) => write!(f, "invalid coordinator config: {msg}"),
125        }
126    }
127}
128
129impl std::error::Error for CoordinatorDispatchError {}
130
131impl fmt::Display for RouteDecision {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(
134            f,
135            "route {} ({}) — {}",
136            self.provider_id, self.model, self.reason
137        )
138    }
139}
140
141impl fmt::Display for DispatchPlan {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "dispatch primary={}", self.primary)?;
144        if !self.fallbacks.is_empty() {
145            let ids: Vec<_> = self
146                .fallbacks
147                .iter()
148                .map(|r| r.provider_id.as_str())
149                .collect();
150            write!(f, ", fallbacks=[{}]", ids.join(", "))?;
151        }
152        Ok(())
153    }
154}
155
156impl fmt::Display for LoadBalanceStrategy {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::RoundRobin => write!(f, "round_robin"),
160            Self::LeastLoaded => write!(f, "least_loaded"),
161            Self::Affinity => write!(f, "affinity"),
162        }
163    }
164}
165
166pub fn validate_coordinator_config(
167    config: &CoordinatorConfig,
168) -> std::result::Result<(), CoordinatorDispatchError> {
169    if config.providers.is_empty() {
170        return Err(CoordinatorDispatchError::InvalidConfig(
171            "providers list must not be empty".to_string(),
172        ));
173    }
174    let mut seen = HashSet::new();
175    for provider in &config.providers {
176        if !seen.insert(provider.id.clone()) {
177            return Err(CoordinatorDispatchError::InvalidConfig(format!(
178                "duplicate provider id '{}'",
179                provider.id
180            )));
181        }
182        if provider.id.trim().is_empty() {
183            return Err(CoordinatorDispatchError::InvalidConfig(
184                "provider id must not be empty".to_string(),
185            ));
186        }
187    }
188    for fallback_id in &config.fallback_chain {
189        if !config.providers.iter().any(|p| &p.id == fallback_id) {
190            return Err(CoordinatorDispatchError::InvalidConfig(format!(
191                "fallback_chain references unknown provider '{fallback_id}'"
192            )));
193        }
194    }
195    Ok(())
196}
197
198fn eligible_providers<'a>(
199    config: &'a CoordinatorConfig,
200    exclude: &HashSet<&str>,
201) -> Vec<&'a ProviderEndpoint> {
202    let mut eligible: Vec<_> = config
203        .providers
204        .iter()
205        .filter(|p| !exclude.contains(p.id.as_str()))
206        .filter(|p| p.capabilities.satisfies(&config.requirements))
207        .collect();
208    eligible.sort_by(|a, b| {
209        let score_a = a.capabilities.score(&config.requirements);
210        let score_b = b.capabilities.score(&config.requirements);
211        score_b.cmp(&score_a).then_with(|| a.id.cmp(&b.id))
212    });
213    eligible
214}
215
216fn pick_by_strategy<'a>(
217    config: &CoordinatorConfig,
218    state: &mut DispatchState,
219    eligible: &'a [&'a ProviderEndpoint],
220) -> &'a ProviderEndpoint {
221    match config.load_balance {
222        LoadBalanceStrategy::RoundRobin => {
223            let idx = state.round_robin_cursor % eligible.len();
224            state.round_robin_cursor = state.round_robin_cursor.saturating_add(1);
225            eligible[idx]
226        }
227        LoadBalanceStrategy::LeastLoaded => eligible
228            .iter()
229            .copied()
230            .min_by_key(|p| (p.in_flight_requests, p.id.as_str()))
231            .expect("eligible is non-empty"),
232        LoadBalanceStrategy::Affinity => {
233            let key = state
234                .session_affinity
235                .as_deref()
236                .or(config.affinity_key.as_deref());
237            if let Some(affinity) = key {
238                if let Some(match_provider) = eligible
239                    .iter()
240                    .copied()
241                    .find(|p| p.affinity_group.as_deref() == Some(affinity))
242                {
243                    return match_provider;
244                }
245            }
246            eligible[0]
247        }
248    }
249}
250
251fn endpoint_to_decision(endpoint: &ProviderEndpoint, reason: impl Into<String>) -> RouteDecision {
252    RouteDecision {
253        provider_id: endpoint.id.clone(),
254        model: endpoint.model.clone(),
255        reason: reason.into(),
256    }
257}
258
259pub fn route_request(
260    config: &CoordinatorConfig,
261    state: &mut DispatchState,
262    exclude: &[&str],
263) -> std::result::Result<RouteDecision, CoordinatorDispatchError> {
264    validate_coordinator_config(config)?;
265    let exclude_set: HashSet<&str> = exclude.iter().copied().collect();
266    let eligible = eligible_providers(config, &exclude_set);
267    if eligible.is_empty() {
268        return Err(CoordinatorDispatchError::NoAvailableProvider(
269            "no provider satisfies requirements".to_string(),
270        ));
271    }
272    let selected = pick_by_strategy(config, state, &eligible);
273    let reason = format!(
274        "selected via {} (score {})",
275        config.load_balance,
276        selected.capabilities.score(&config.requirements)
277    );
278    Ok(endpoint_to_decision(selected, reason))
279}
280
281pub fn select_fallback(
282    config: &CoordinatorConfig,
283    plan: &DispatchPlan,
284    failed_provider_id: &str,
285    attempt_errors: &[String],
286) -> std::result::Result<Option<RouteDecision>, CoordinatorDispatchError> {
287    validate_coordinator_config(config)?;
288    let mut tried: HashSet<&str> = HashSet::new();
289    tried.insert(plan.primary.provider_id.as_str());
290    for fb in &plan.fallbacks {
291        tried.insert(fb.provider_id.as_str());
292    }
293    tried.insert(failed_provider_id);
294    for fallback_id in &config.fallback_chain {
295        if tried.contains(fallback_id.as_str()) {
296            continue;
297        }
298        let Some(endpoint) = config.providers.iter().find(|p| &p.id == fallback_id) else {
299            continue;
300        };
301        if !endpoint.capabilities.satisfies(&config.requirements) {
302            continue;
303        }
304        return Ok(Some(endpoint_to_decision(
305            endpoint,
306            format!("fallback after failure of '{failed_provider_id}'"),
307        )));
308    }
309    let remaining: Vec<_> = config
310        .providers
311        .iter()
312        .filter(|p| !tried.contains(p.id.as_str()))
313        .filter(|p| p.capabilities.satisfies(&config.requirements))
314        .collect();
315    if let Some(endpoint) = remaining.first() {
316        return Ok(Some(endpoint_to_decision(
317            endpoint,
318            format!("secondary fallback after '{failed_provider_id}'"),
319        )));
320    }
321    if attempt_errors.is_empty() {
322        Ok(None)
323    } else {
324        Err(CoordinatorDispatchError::AllFailed(attempt_errors.to_vec()))
325    }
326}
327
328pub fn parse_coordinator_response(
329    payload: &str,
330) -> std::result::Result<DispatchPlan, CoordinatorDispatchError> {
331    let value: Value = serde_json::from_str(payload)
332        .map_err(|e| CoordinatorDispatchError::InvalidConfig(format!("invalid JSON: {e}")))?;
333    let primary_value = value.get("primary").ok_or_else(|| {
334        CoordinatorDispatchError::InvalidConfig("missing 'primary' field".to_string())
335    })?;
336    let primary: RouteDecision = serde_json::from_value(primary_value.clone()).map_err(|e| {
337        CoordinatorDispatchError::InvalidConfig(format!("invalid primary route: {e}"))
338    })?;
339    let fallbacks = match value.get("fallbacks") {
340        Some(Value::Array(items)) => items
341            .iter()
342            .map(|item| {
343                serde_json::from_value(item.clone()).map_err(|e| {
344                    CoordinatorDispatchError::InvalidConfig(format!("invalid fallback route: {e}"))
345                })
346            })
347            .collect::<std::result::Result<Vec<_>, _>>()?,
348        Some(_) => {
349            return Err(CoordinatorDispatchError::InvalidConfig(
350                "'fallbacks' must be an array".to_string(),
351            ));
352        }
353        None => Vec::new(),
354    };
355    if primary.provider_id.trim().is_empty() {
356        return Err(CoordinatorDispatchError::InvalidConfig(
357            "primary provider_id must not be empty".to_string(),
358        ));
359    }
360    Ok(DispatchPlan { primary, fallbacks })
361}
362
363pub fn build_dispatch_plan(
364    config: &CoordinatorConfig,
365    state: &mut DispatchState,
366) -> std::result::Result<DispatchPlan, CoordinatorDispatchError> {
367    validate_coordinator_config(config)?;
368    let primary = route_request(config, state, &[])?;
369    let mut fallbacks = Vec::new();
370    let mut failed_id = primary.provider_id.clone();
371    loop {
372        let plan_snapshot = DispatchPlan {
373            primary: primary.clone(),
374            fallbacks: fallbacks.clone(),
375        };
376        match select_fallback(config, &plan_snapshot, &failed_id, &[]) {
377            Ok(Some(next)) => {
378                if fallbacks.iter().any(|f| f.provider_id == next.provider_id) {
379                    break;
380                }
381                failed_id = next.provider_id.clone();
382                fallbacks.push(next);
383            }
384            Ok(None) => break,
385            Err(e) => return Err(e),
386        }
387        if !config.fallback_chain.is_empty() && fallbacks.len() >= config.fallback_chain.len() {
388            break;
389        }
390    }
391    Ok(DispatchPlan { primary, fallbacks })
392}
393
394/// Configuration for tool calling coordination behavior.
395///
396/// Controls how the coordinator handles multi-turn tool calling,
397/// including iteration limits, parallelism, and timeout settings.
398#[derive(Debug, Clone)]
399pub struct ToolCallingConfig {
400    /// Maximum number of LLM iterations (not tool calls) before stopping.
401    /// Each iteration is one round-trip to the LLM.
402    pub max_iterations: usize,
403
404    /// Whether to execute multiple tool calls in parallel.
405    /// When false, tools are executed sequentially.
406    pub parallel_execution: bool,
407
408    /// Timeout for individual tool execution.
409    pub tool_timeout: Duration,
410
411    /// Whether to include tool results in the final response context.
412    pub include_tool_results: bool,
413
414    /// Whether to stop on the first tool error, or continue with remaining tools.
415    pub stop_on_error: bool,
416}
417
418impl Default for ToolCallingConfig {
419    fn default() -> Self {
420        Self {
421            max_iterations: 10,
422            parallel_execution: true,
423            tool_timeout: Duration::from_secs(30),
424            include_tool_results: true,
425            stop_on_error: false,
426        }
427    }
428}
429
430/// Record of a single tool call execution.
431///
432/// Captures all details about a tool invocation including timing,
433/// success status, and any errors that occurred.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct ToolCallRecord {
436    /// Unique identifier for this tool call (from the LLM).
437    pub id: String,
438    /// Name of the tool that was called.
439    pub name: String,
440    /// Arguments passed to the tool.
441    pub arguments: serde_json::Value,
442    /// Result returned by the tool (or error object).
443    pub result: serde_json::Value,
444    /// Whether the tool execution was successful.
445    pub success: bool,
446    /// Time taken to execute the tool in milliseconds.
447    pub duration_ms: u64,
448    /// Error message if the tool failed.
449    pub error: Option<String>,
450}
451
452/// Reason why a tool coordination session ended.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub enum FinishReason {
455    /// Model decided to stop (no more tool calls).
456    Stop,
457    /// Hit the maximum iterations limit.
458    MaxIterations,
459    /// An unrecoverable error occurred.
460    Error(String),
461    /// Model tried to call an unknown tool.
462    UnknownTool(String),
463}
464
465impl std::fmt::Display for FinishReason {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        match self {
468            FinishReason::Stop => write!(f, "stop"),
469            FinishReason::MaxIterations => write!(f, "max_iterations"),
470            FinishReason::Error(e) => write!(f, "error: {}", e),
471            FinishReason::UnknownTool(t) => write!(f, "unknown_tool: {}", t),
472        }
473    }
474}
475
476/// A message in a tool-calling conversation.
477///
478/// Represents all message types that can appear in a multi-turn
479/// conversation with tool calling.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ConversationMessage {
482    /// The role of the message sender.
483    pub role: MessageRole,
484    /// The text content of the message.
485    pub content: String,
486    /// Tool calls requested by the assistant (only for Assistant role).
487    #[serde(default, skip_serializing_if = "Vec::is_empty")]
488    pub tool_calls: Vec<ToolCall>,
489    /// Tool result content (only for Tool role).
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub tool_call_id: Option<String>,
492    /// Multimodal parts. Empty means use `content` as text.
493    #[serde(default, skip_serializing_if = "Vec::is_empty")]
494    pub parts: Vec<ContentPart>,
495    /// Per-message cache control.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub cache_control: Option<CacheControl>,
498    /// Reasoning content to round-trip to the provider.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub reasoning_content: Option<String>,
501    /// Previous Responses API id.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub previous_response_id: Option<String>,
504    /// Whether the provider should store the response.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub store: Option<bool>,
507}
508
509/// Role of a message sender in a tool-calling conversation.
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "lowercase")]
512pub enum MessageRole {
513    /// System instructions.
514    System,
515    /// User message.
516    User,
517    /// Assistant response.
518    Assistant,
519    /// Tool execution result.
520    Tool,
521}
522
523impl ConversationMessage {
524    /// Create a system message.
525    pub fn system(content: impl Into<String>) -> Self {
526        Self {
527            role: MessageRole::System,
528            content: content.into(),
529            tool_calls: Vec::new(),
530            tool_call_id: None,
531            parts: Vec::new(),
532            cache_control: None,
533            reasoning_content: None,
534            previous_response_id: None,
535            store: None,
536        }
537    }
538
539    /// Create a user message.
540    pub fn user(content: impl Into<String>) -> Self {
541        Self {
542            role: MessageRole::User,
543            content: content.into(),
544            tool_calls: Vec::new(),
545            tool_call_id: None,
546            parts: Vec::new(),
547            cache_control: None,
548            reasoning_content: None,
549            previous_response_id: None,
550            store: None,
551        }
552    }
553
554    /// Create an assistant message with optional tool calls.
555    pub fn assistant(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
556        Self {
557            role: MessageRole::Assistant,
558            content: content.into(),
559            tool_calls,
560            tool_call_id: None,
561            parts: Vec::new(),
562            cache_control: None,
563            reasoning_content: None,
564            previous_response_id: None,
565            store: None,
566        }
567    }
568
569    /// Create a tool result message.
570    pub fn tool_result(tool_call_id: impl Into<String>, result: &serde_json::Value) -> Self {
571        Self {
572            role: MessageRole::Tool,
573            content: serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()),
574            tool_calls: Vec::new(),
575            tool_call_id: Some(tool_call_id.into()),
576            parts: Vec::new(),
577            cache_control: None,
578            reasoning_content: None,
579            previous_response_id: None,
580            store: None,
581        }
582    }
583
584    /// Convert to the simple (role, content) format for LLMClient::generate_with_history.
585    pub fn to_role_content(&self) -> (String, String) {
586        let role = match self.role {
587            MessageRole::System => "system",
588            MessageRole::User => "user",
589            MessageRole::Assistant => "assistant",
590            MessageRole::Tool => "tool",
591        };
592        (role.to_string(), self.content.clone())
593    }
594}
595
596/// Result of a complete tool coordination session.
597///
598/// Contains all information about what happened during the multi-turn
599/// conversation, including the final response, all tool calls made,
600/// token usage, and message history.
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct CoordinatorResult {
603    /// Final text response from the model.
604    pub content: String,
605
606    /// All tool calls made during the session.
607    pub tool_calls: Vec<ToolCallRecord>,
608
609    /// Number of LLM iterations (round-trips) performed.
610    pub iterations: usize,
611
612    /// Why the session ended.
613    pub finish_reason: FinishReason,
614
615    /// Accumulated token usage across all iterations.
616    pub total_usage: TokenUsage,
617
618    /// Full message history (useful for debugging and training data).
619    pub message_history: Vec<ConversationMessage>,
620}
621
622/// Generic tool coordinator that works with any LLMClient.
623///
624/// Manages multi-turn tool calling conversations by:
625/// 1. Sending prompts with tool definitions to the LLM
626/// 2. Parsing tool call requests from the response
627/// 3. Executing tools and collecting results
628/// 4. Sending results back to the LLM
629/// 5. Repeating until the LLM produces a final response
630///
631/// # Type Parameters
632///
633/// The coordinator is generic over the LLMClient, but typically you'll use
634/// it with `Box<dyn LLMClient>` for maximum flexibility.
635pub struct ToolCoordinator {
636    client: Box<dyn LLMClient>,
637    /// Ordered fallback chain: `(provider_name, client)` pairs tried when
638    /// the primary fails with a retryable error.
639    #[allow(dead_code)]
640    fallback_chain: Vec<(String, Box<dyn LLMClient>)>,
641    tools: Arc<Tools>,
642    config: ToolCallingConfig,
643    observability: Option<Arc<dyn crate::observability::ObservabilitySink>>,
644}
645
646impl ToolCoordinator {
647    /// Create a new ToolCoordinator with the given client, tools, and config.
648    pub fn new(client: Box<dyn LLMClient>, tools: Arc<Tools>, config: ToolCallingConfig) -> Self {
649        Self {
650            client,
651            fallback_chain: Vec::new(),
652            tools,
653            config,
654            observability: None,
655        }
656    }
657
658    /// Create a new ToolCoordinator with default configuration.
659    pub fn with_defaults(client: Box<dyn LLMClient>, tools: Arc<Tools>) -> Self {
660        Self::new(client, tools, ToolCallingConfig::default())
661    }
662
663    /// Create a new ToolCoordinator with a fallback chain.
664    pub fn with_fallbacks(
665        client: Box<dyn LLMClient>,
666        fallback_chain: Vec<(String, Box<dyn LLMClient>)>,
667        tools: Arc<Tools>,
668        config: ToolCallingConfig,
669    ) -> Self {
670        Self {
671            client,
672            fallback_chain,
673            tools,
674            config,
675            observability: None,
676        }
677    }
678
679    /// Attach an observability sink to this coordinator.
680    pub fn with_observability(
681        mut self,
682        obs: Arc<dyn crate::observability::ObservabilitySink>,
683    ) -> Self {
684        self.observability = Some(obs);
685        self
686    }
687
688    /// Execute a complete tool-calling conversation loop.
689    ///
690    /// This method handles the full tool calling loop:
691    /// 1. Send the initial prompt with available tools
692    /// 2. If the model requests tool calls, execute them
693    /// 3. Send tool results back to the model
694    /// 4. Repeat until the model produces a final response or max iterations reached
695    ///
696    /// # Arguments
697    ///
698    /// * `system` - Optional system prompt
699    /// * `prompt` - The user's prompt
700    /// * `ctx` - Cordis context for `Tools::list` / `Tools::resolve` tenant derivation
701    ///
702    /// # Returns
703    ///
704    /// A `CoordinatorResult` containing the final response, all tool calls made,
705    /// and execution metadata.
706    pub async fn execute(
707        &self,
708        system: Option<&str>,
709        prompt: &str,
710        ctx: &Arc<Context>,
711    ) -> Result<CoordinatorResult> {
712        let tools = self.tools.list(ctx);
713        let mut messages: Vec<ConversationMessage> = Vec::new();
714        let mut all_tool_calls: Vec<ToolCallRecord> = Vec::new();
715        let mut total_usage = TokenUsage::default();
716
717        // Add system message if provided
718        if let Some(sys) = system {
719            messages.push(ConversationMessage::system(sys));
720        }
721
722        // Add user message
723        messages.push(ConversationMessage::user(prompt));
724
725        for iteration in 0..self.config.max_iterations {
726            // Call LLM with tools
727            let llm_start = Instant::now();
728            let response = self
729                .client
730                .generate_with_tools_and_history(&messages, &tools)
731                .await?;
732            let llm_latency = llm_start.elapsed().as_millis() as i64;
733
734            // Log the LLM call
735            if let Some(obs) = &self.observability {
736                let prompt_tok = response
737                    .usage
738                    .as_ref()
739                    .map(|u| u.prompt_tokens as i64)
740                    .unwrap_or(0);
741                let completion_tok = response
742                    .usage
743                    .as_ref()
744                    .map(|u| u.completion_tokens as i64)
745                    .unwrap_or(0);
746                let record = crate::observability::LlmCallRecord {
747                    step_index: iteration as i32,
748                    provider: "unknown".to_string(),
749                    model: "unknown".to_string(),
750                    prompt_tokens: prompt_tok,
751                    completion_tokens: completion_tok,
752                    latency_ms: llm_latency,
753                    status: "success".to_string(),
754                    cached_tokens: response.usage.as_ref().and_then(|u| u.cached_tokens),
755                    total_time_ms: Some(llm_latency),
756                };
757                let _ = obs.log_llm_call(record).await;
758            }
759
760            // Accumulate usage
761            if let Some(usage) = &response.usage {
762                total_usage = TokenUsage::new(
763                    total_usage.prompt_tokens + usage.prompt_tokens,
764                    total_usage.completion_tokens + usage.completion_tokens,
765                );
766            }
767
768            // Add assistant message to history
769            messages.push(ConversationMessage::assistant(
770                &response.content,
771                response.tool_calls.clone(),
772            ));
773
774            // Check if we're done (no tool calls)
775            if response.tool_calls.is_empty() {
776                return Ok(CoordinatorResult {
777                    content: response.content,
778                    tool_calls: all_tool_calls,
779                    iterations: iteration + 1,
780                    finish_reason: FinishReason::Stop,
781                    total_usage,
782                    message_history: messages,
783                });
784            }
785
786            // Validate that all requested tools exist
787            for tool_call in &response.tool_calls {
788                if self.tools.resolve(ctx, &tool_call.name).is_none() {
789                    return Ok(CoordinatorResult {
790                        content: response.content,
791                        tool_calls: all_tool_calls,
792                        iterations: iteration + 1,
793                        finish_reason: FinishReason::UnknownTool(tool_call.name.clone()),
794                        total_usage,
795                        message_history: messages,
796                    });
797                }
798            }
799
800            // Execute tool calls
801            let tool_start = Instant::now();
802            let tool_results = self.execute_tool_calls(ctx, &response.tool_calls).await?;
803            let tool_latency = tool_start.elapsed().as_millis() as i64;
804
805            // Record tool calls and add results to message history
806            for record in tool_results.into_iter() {
807                // Log the tool call
808                if let Some(obs) = &self.observability {
809                    let status = if record.success {
810                        "success".to_string()
811                    } else {
812                        "error".to_string()
813                    };
814                    let tool_record = crate::observability::ToolCallRecord {
815                        step_index: iteration as i32,
816                        tool_name: record.name.clone(),
817                        tool_type: "builtin".to_string(),
818                        arguments: record.arguments.clone(),
819                        result: Some(record.result.clone()),
820                        latency_ms: tool_latency,
821                        status,
822                    };
823                    let _ = obs.log_tool_call(tool_record).await;
824                }
825
826                // Add tool result to messages
827                messages.push(ConversationMessage::tool_result(&record.id, &record.result));
828                all_tool_calls.push(record);
829            }
830        }
831
832        // Hit max iterations
833        Ok(CoordinatorResult {
834            content: messages
835                .last()
836                .map(|m| m.content.clone())
837                .unwrap_or_default(),
838            tool_calls: all_tool_calls,
839            iterations: self.config.max_iterations,
840            finish_reason: FinishReason::MaxIterations,
841            total_usage,
842            message_history: messages,
843        })
844    }
845
846    /// Execute tool calls, either in parallel or sequentially based on config.
847    async fn execute_tool_calls(
848        &self,
849        ctx: &Arc<Context>,
850        calls: &[ToolCall],
851    ) -> Result<Vec<ToolCallRecord>> {
852        if self.config.parallel_execution {
853            self.execute_parallel(ctx, calls).await
854        } else {
855            self.execute_sequential(ctx, calls).await
856        }
857    }
858
859    /// Execute tool calls in parallel.
860    async fn execute_parallel(
861        &self,
862        ctx: &Arc<Context>,
863        calls: &[ToolCall],
864    ) -> Result<Vec<ToolCallRecord>> {
865        let futures = calls.iter().map(|call| self.execute_single_tool(ctx, call));
866        let results = join_all(futures).await;
867
868        let mut records = Vec::with_capacity(results.len());
869        for result in results {
870            match result {
871                Ok(record) => records.push(record),
872                Err(e) if self.config.stop_on_error => return Err(e),
873                Err(e) => {
874                    // Create an error record for failed tools
875                    records.push(ToolCallRecord {
876                        id: "error".to_string(),
877                        name: "unknown".to_string(),
878                        arguments: serde_json::Value::Null,
879                        result: serde_json::json!({"error": e.to_string()}),
880                        success: false,
881                        duration_ms: 0,
882                        error: Some(e.to_string()),
883                    });
884                }
885            }
886        }
887        Ok(records)
888    }
889
890    /// Execute tool calls sequentially.
891    async fn execute_sequential(
892        &self,
893        ctx: &Arc<Context>,
894        calls: &[ToolCall],
895    ) -> Result<Vec<ToolCallRecord>> {
896        let mut records = Vec::with_capacity(calls.len());
897        for call in calls {
898            match self.execute_single_tool(ctx, call).await {
899                Ok(record) => records.push(record),
900                Err(e) if self.config.stop_on_error => return Err(e),
901                Err(e) => {
902                    records.push(ToolCallRecord {
903                        id: call.id.clone(),
904                        name: call.name.clone(),
905                        arguments: call.arguments.clone(),
906                        result: serde_json::json!({"error": e.to_string()}),
907                        success: false,
908                        duration_ms: 0,
909                        error: Some(e.to_string()),
910                    });
911                }
912            }
913        }
914        Ok(records)
915    }
916
917    /// Execute a single tool call with timeout.
918    async fn execute_single_tool(
919        &self,
920        ctx: &Arc<Context>,
921        call: &ToolCall,
922    ) -> Result<ToolCallRecord> {
923        let start = Instant::now();
924
925        let result = timeout(
926            self.config.tool_timeout,
927            self.tools.execute(ctx, &call.name, call.arguments.clone()),
928        )
929        .await;
930
931        let duration_ms = start.elapsed().as_millis() as u64;
932
933        match result {
934            Ok(Ok(value)) => Ok(ToolCallRecord {
935                id: call.id.clone(),
936                name: call.name.clone(),
937                arguments: call.arguments.clone(),
938                result: value,
939                success: true,
940                duration_ms,
941                error: None,
942            }),
943            Ok(Err(e)) => Ok(ToolCallRecord {
944                id: call.id.clone(),
945                name: call.name.clone(),
946                arguments: call.arguments.clone(),
947                result: serde_json::json!({"error": e.to_string()}),
948                success: false,
949                duration_ms,
950                error: Some(e.to_string()),
951            }),
952            Err(_) => Ok(ToolCallRecord {
953                id: call.id.clone(),
954                name: call.name.clone(),
955                arguments: call.arguments.clone(),
956                result: serde_json::json!({"error": "Tool execution timed out"}),
957                success: false,
958                duration_ms,
959                error: Some("Tool execution timed out".to_string()),
960            }),
961        }
962    }
963
964    /// Get a reference to the underlying LLM client.
965    pub fn client(&self) -> &dyn LLMClient {
966        self.client.as_ref()
967    }
968
969    /// Get a reference to the Tools capability.
970    pub fn tools(&self) -> &Arc<Tools> {
971        &self.tools
972    }
973
974    /// Get a reference to the configuration.
975    pub fn config(&self) -> &ToolCallingConfig {
976        &self.config
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use crate::capabilities::ModelCapabilities;
984    use crate::client::{LLMClient, LLMResponse, TokenUsage};
985    use ares_types::types::{Result, ToolCall, ToolDefinition};
986
987    fn serde_roundtrip<T>(value: &T) -> T
988    where
989        T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
990    {
991        let json = serde_json::to_string(value).unwrap();
992        let decoded: T = serde_json::from_str(&json).unwrap();
993        assert_eq!(*value, decoded);
994        decoded
995    }
996
997    #[test]
998    fn test_tool_calling_config_default() {
999        let config = ToolCallingConfig::default();
1000        assert_eq!(config.max_iterations, 10);
1001        assert!(config.parallel_execution);
1002        assert_eq!(config.tool_timeout, Duration::from_secs(30));
1003        assert!(config.include_tool_results);
1004        assert!(!config.stop_on_error);
1005    }
1006
1007    #[test]
1008    fn test_tool_calling_config_clone() {
1009        let original = ToolCallingConfig::default();
1010        let mut cloned = original.clone();
1011        cloned.max_iterations = 2;
1012        cloned.parallel_execution = false;
1013        cloned.tool_timeout = Duration::from_secs(5);
1014        cloned.include_tool_results = false;
1015        cloned.stop_on_error = true;
1016
1017        assert_eq!(original.max_iterations, 10);
1018        assert!(original.parallel_execution);
1019        assert_eq!(original.tool_timeout, Duration::from_secs(30));
1020        assert!(original.include_tool_results);
1021        assert!(!original.stop_on_error);
1022
1023        assert_eq!(cloned.max_iterations, 2);
1024        assert!(!cloned.parallel_execution);
1025        assert_eq!(cloned.tool_timeout, Duration::from_secs(5));
1026        assert!(!cloned.include_tool_results);
1027        assert!(cloned.stop_on_error);
1028    }
1029
1030    #[test]
1031    fn test_finish_reason_serialization() {
1032        for reason in [
1033            FinishReason::Stop,
1034            FinishReason::MaxIterations,
1035            FinishReason::Error("timeout".to_string()),
1036            FinishReason::UnknownTool("missing".to_string()),
1037        ] {
1038            serde_roundtrip(&reason);
1039        }
1040    }
1041
1042    #[test]
1043    fn test_message_role_serialization() {
1044        for role in [
1045            MessageRole::System,
1046            MessageRole::User,
1047            MessageRole::Assistant,
1048            MessageRole::Tool,
1049        ] {
1050            let json = serde_json::to_string(&role).unwrap();
1051            assert!(json.chars().all(|c| !c.is_uppercase()));
1052            serde_roundtrip(&role);
1053        }
1054    }
1055
1056    /// `CoordinatorResult` is the concrete session output type used by `execute`.
1057    type SessionResult = CoordinatorResult;
1058
1059    #[test]
1060    fn test_coordinator_result_type_alias() {
1061        let result: SessionResult = CoordinatorResult {
1062            content: "All done".to_string(),
1063            tool_calls: vec![ToolCallRecord {
1064                id: "call_1".to_string(),
1065                name: "calculator".to_string(),
1066                arguments: serde_json::json!({"a": 1, "b": 1}),
1067                result: serde_json::json!({"sum": 2}),
1068                success: true,
1069                duration_ms: 12,
1070                error: None,
1071            }],
1072            iterations: 2,
1073            finish_reason: FinishReason::Stop,
1074            total_usage: TokenUsage::new(30, 15),
1075            message_history: vec![
1076                ConversationMessage::system("sys"),
1077                ConversationMessage::user("go"),
1078            ],
1079        };
1080
1081        assert_eq!(result.content, "All done");
1082        assert_eq!(result.tool_calls.len(), 1);
1083        assert_eq!(result.iterations, 2);
1084        assert_eq!(result.finish_reason, FinishReason::Stop);
1085        assert_eq!(result.total_usage.prompt_tokens, 30);
1086    }
1087
1088    #[test]
1089    fn test_coordinator_result_serde_roundtrip() {
1090        let result = CoordinatorResult {
1091            content: "done".to_string(),
1092            tool_calls: Vec::new(),
1093            iterations: 1,
1094            finish_reason: FinishReason::MaxIterations,
1095            total_usage: TokenUsage::default(),
1096            message_history: vec![ConversationMessage::user("ping")],
1097        };
1098        let json = serde_json::to_string(&result).unwrap();
1099        let decoded: CoordinatorResult = serde_json::from_str(&json).unwrap();
1100        assert_eq!(decoded.content, result.content);
1101        assert_eq!(decoded.tool_calls.len(), result.tool_calls.len());
1102        assert_eq!(decoded.iterations, result.iterations);
1103        assert_eq!(decoded.finish_reason, result.finish_reason);
1104        assert_eq!(decoded.total_usage, result.total_usage);
1105        assert_eq!(decoded.message_history.len(), result.message_history.len());
1106        assert_eq!(
1107            decoded.message_history[0].role,
1108            result.message_history[0].role
1109        );
1110    }
1111
1112    #[test]
1113    fn test_conversation_message_serde_roundtrip() {
1114        let tool_calls = vec![ToolCall {
1115            id: "call_1".to_string(),
1116            name: "search".to_string(),
1117            arguments: serde_json::json!({"q": "ares"}),
1118        }];
1119
1120        for msg in [
1121            ConversationMessage::system("system prompt"),
1122            ConversationMessage::user("hello"),
1123            ConversationMessage::assistant("thinking", tool_calls),
1124            ConversationMessage::tool_result("call_1", &serde_json::json!({"hits": 1})),
1125        ] {
1126            let json = serde_json::to_string(&msg).unwrap();
1127            let decoded: ConversationMessage = serde_json::from_str(&json).unwrap();
1128            assert_eq!(decoded.role, msg.role);
1129            assert_eq!(decoded.content, msg.content);
1130            assert_eq!(decoded.tool_calls.len(), msg.tool_calls.len());
1131            assert_eq!(decoded.tool_call_id, msg.tool_call_id);
1132        }
1133    }
1134
1135    #[test]
1136    fn test_conversation_message_system() {
1137        let msg = ConversationMessage::system("You are a helpful assistant.");
1138        assert_eq!(msg.role, MessageRole::System);
1139        assert_eq!(msg.content, "You are a helpful assistant.");
1140        assert!(msg.tool_calls.is_empty());
1141        assert!(msg.tool_call_id.is_none());
1142    }
1143
1144    #[test]
1145    fn test_conversation_message_user() {
1146        let msg = ConversationMessage::user("Hello!");
1147        assert_eq!(msg.role, MessageRole::User);
1148        assert_eq!(msg.content, "Hello!");
1149    }
1150
1151    #[test]
1152    fn test_conversation_message_assistant_with_tool_calls() {
1153        let tool_calls = vec![ToolCall {
1154            id: "call_1".to_string(),
1155            name: "calculator".to_string(),
1156            arguments: serde_json::json!({"a": 1, "b": 2}),
1157        }];
1158        let msg = ConversationMessage::assistant("Let me calculate that.", tool_calls.clone());
1159        assert_eq!(msg.role, MessageRole::Assistant);
1160        assert_eq!(msg.tool_calls.len(), 1);
1161        assert_eq!(msg.tool_calls[0].name, "calculator");
1162    }
1163
1164    #[test]
1165    fn test_conversation_message_tool_result() {
1166        let result = serde_json::json!({"result": 42});
1167        let msg = ConversationMessage::tool_result("call_1", &result);
1168        assert_eq!(msg.role, MessageRole::Tool);
1169        assert_eq!(msg.tool_call_id, Some("call_1".to_string()));
1170        assert!(msg.content.contains("42"));
1171    }
1172
1173    #[test]
1174    fn test_finish_reason_display() {
1175        assert_eq!(FinishReason::Stop.to_string(), "stop");
1176        assert_eq!(FinishReason::MaxIterations.to_string(), "max_iterations");
1177        assert_eq!(
1178            FinishReason::Error("test error".to_string()).to_string(),
1179            "error: test error"
1180        );
1181        assert_eq!(
1182            FinishReason::UnknownTool("unknown".to_string()).to_string(),
1183            "unknown_tool: unknown"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_tool_call_record_serialization() {
1189        let record = ToolCallRecord {
1190            id: "call_1".to_string(),
1191            name: "test_tool".to_string(),
1192            arguments: serde_json::json!({"input": "test"}),
1193            result: serde_json::json!({"output": "result"}),
1194            success: true,
1195            duration_ms: 100,
1196            error: None,
1197        };
1198
1199        let json = serde_json::to_string(&record).unwrap();
1200        assert!(json.contains("test_tool"));
1201        assert!(json.contains("\"success\":true"));
1202    }
1203
1204    struct MockToolFlowClient {
1205        calls: std::sync::atomic::AtomicUsize,
1206    }
1207
1208    impl MockToolFlowClient {
1209        fn new() -> Self {
1210            Self {
1211                calls: std::sync::atomic::AtomicUsize::new(0),
1212            }
1213        }
1214    }
1215
1216    #[async_trait::async_trait]
1217    impl LLMClient for MockToolFlowClient {
1218        async fn generate(&self, _prompt: &str) -> Result<String> {
1219            Ok(String::new())
1220        }
1221
1222        async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
1223            Ok(String::new())
1224        }
1225
1226        async fn generate_with_history(
1227            &self,
1228            _messages: &[(String, String)],
1229        ) -> Result<LLMResponse> {
1230            Ok(LLMResponse {
1231                content: String::new(),
1232                tool_calls: vec![],
1233                finish_reason: "stop".into(),
1234                usage: None,
1235                reasoning_content: None,
1236                response_id: None,
1237            })
1238        }
1239
1240        async fn generate_with_tools(
1241            &self,
1242            _prompt: &str,
1243            _tools: &[ToolDefinition],
1244        ) -> Result<LLMResponse> {
1245            Ok(LLMResponse {
1246                content: String::new(),
1247                tool_calls: vec![],
1248                finish_reason: "stop".into(),
1249                usage: None,
1250                reasoning_content: None,
1251                response_id: None,
1252            })
1253        }
1254
1255        async fn generate_with_tools_and_history(
1256            &self,
1257            _messages: &[ConversationMessage],
1258            _tools: &[ToolDefinition],
1259        ) -> Result<LLMResponse> {
1260            let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1261            if n == 0 {
1262                Ok(LLMResponse {
1263                    content: "Let me calculate".to_string(),
1264                    tool_calls: vec![ToolCall {
1265                        id: "call_1".to_string(),
1266                        name: "calculator".to_string(),
1267                        arguments: serde_json::json!({"operation": "add", "a": 2, "b": 2}),
1268                    }],
1269                    finish_reason: "tool_calls".to_string(),
1270                    usage: Some(TokenUsage::new(10, 5)),
1271                    reasoning_content: None,
1272                    response_id: None,
1273                })
1274            } else {
1275                Ok(LLMResponse {
1276                    content: "The answer is 4".to_string(),
1277                    tool_calls: vec![],
1278                    finish_reason: "stop".to_string(),
1279                    usage: Some(TokenUsage::new(5, 3)),
1280                    reasoning_content: None,
1281                    response_id: None,
1282                })
1283            }
1284        }
1285
1286        async fn stream(
1287            &self,
1288            _prompt: &str,
1289        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1290            Err(ares_types::types::AppError::Internal("not used".into()))
1291        }
1292
1293        async fn stream_with_system(
1294            &self,
1295            _system: &str,
1296            _prompt: &str,
1297        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1298            Err(ares_types::types::AppError::Internal("not used".into()))
1299        }
1300
1301        async fn stream_with_history(
1302            &self,
1303            _messages: &[(String, String)],
1304        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1305            Err(ares_types::types::AppError::Internal("not used".into()))
1306        }
1307
1308        fn model_name(&self) -> &str {
1309            "mock"
1310        }
1311    }
1312
1313    #[tokio::test]
1314    async fn test_tool_calling_flow_with_mock() {
1315        use ares_tools::calculator::Calculator;
1316        use std::sync::Arc;
1317
1318        let tools = Arc::new(Tools::from_static([Arc::new(Calculator) as Arc<dyn Tool>]));
1319        let ctx = Context::new_root();
1320
1321        let coordinator = ToolCoordinator::new(
1322            Box::new(MockToolFlowClient::new()),
1323            tools,
1324            ToolCallingConfig::default(),
1325        );
1326
1327        let result = coordinator
1328            .execute(None, "What is 2 + 2?", &ctx)
1329            .await
1330            .expect("coordinator should succeed");
1331
1332        assert_eq!(result.finish_reason, FinishReason::Stop);
1333        assert_eq!(result.tool_calls.len(), 1);
1334        assert_eq!(result.tool_calls[0].name, "calculator");
1335        assert!(result.tool_calls[0].success);
1336        assert_eq!(result.iterations, 2);
1337        assert!(result.content.contains('4'));
1338    }
1339
1340    #[tokio::test]
1341    async fn test_tool_calling_unknown_tool_stops() {
1342        struct UnknownToolClient;
1343
1344        #[async_trait::async_trait]
1345        impl LLMClient for UnknownToolClient {
1346            async fn generate(&self, _prompt: &str) -> Result<String> {
1347                Ok(String::new())
1348            }
1349            async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
1350                Ok(String::new())
1351            }
1352            async fn generate_with_history(
1353                &self,
1354                _messages: &[(String, String)],
1355            ) -> Result<LLMResponse> {
1356                Ok(LLMResponse {
1357                    content: String::new(),
1358                    tool_calls: vec![],
1359                    finish_reason: "stop".into(),
1360                    usage: None,
1361                    reasoning_content: None,
1362                    response_id: None,
1363                })
1364            }
1365            async fn generate_with_tools(
1366                &self,
1367                _prompt: &str,
1368                _tools: &[ToolDefinition],
1369            ) -> Result<LLMResponse> {
1370                Ok(LLMResponse {
1371                    content: String::new(),
1372                    tool_calls: vec![],
1373                    finish_reason: "stop".into(),
1374                    usage: None,
1375                    reasoning_content: None,
1376                    response_id: None,
1377                })
1378            }
1379            async fn generate_with_tools_and_history(
1380                &self,
1381                _messages: &[ConversationMessage],
1382                _tools: &[ToolDefinition],
1383            ) -> Result<LLMResponse> {
1384                Ok(LLMResponse {
1385                    content: "calling".into(),
1386                    tool_calls: vec![ToolCall {
1387                        id: "1".into(),
1388                        name: "missing_tool".into(),
1389                        arguments: serde_json::json!({}),
1390                    }],
1391                    finish_reason: "tool_calls".into(),
1392                    usage: None,
1393                    reasoning_content: None,
1394                    response_id: None,
1395                })
1396            }
1397            async fn stream(
1398                &self,
1399                _prompt: &str,
1400            ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1401            {
1402                Err(ares_types::types::AppError::Internal("n/a".into()))
1403            }
1404            async fn stream_with_system(
1405                &self,
1406                _system: &str,
1407                _prompt: &str,
1408            ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1409            {
1410                Err(ares_types::types::AppError::Internal("n/a".into()))
1411            }
1412            async fn stream_with_history(
1413                &self,
1414                _messages: &[(String, String)],
1415            ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1416            {
1417                Err(ares_types::types::AppError::Internal("n/a".into()))
1418            }
1419            fn model_name(&self) -> &str {
1420                "mock"
1421            }
1422        }
1423
1424        let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
1425        let ctx = Context::new_root();
1426        let coordinator = ToolCoordinator::new(
1427            Box::new(UnknownToolClient),
1428            tools,
1429            ToolCallingConfig::default(),
1430        );
1431        let result = coordinator.execute(None, "go", &ctx).await.unwrap();
1432        assert!(matches!(result.finish_reason, FinishReason::UnknownTool(_)));
1433    }
1434
1435    fn dispatch_endpoint(id: &str, model: &str, caps: ModelCapabilities) -> ProviderEndpoint {
1436        ProviderEndpoint {
1437            id: id.to_string(),
1438            model: model.to_string(),
1439            capabilities: caps,
1440            in_flight_requests: 0,
1441            affinity_group: None,
1442        }
1443    }
1444
1445    fn dispatch_test_config(providers: Vec<ProviderEndpoint>) -> CoordinatorConfig {
1446        CoordinatorConfig {
1447            providers,
1448            fallback_chain: vec![],
1449            load_balance: LoadBalanceStrategy::RoundRobin,
1450            requirements: CapabilityRequirements::default(),
1451            affinity_key: None,
1452        }
1453    }
1454
1455    #[test]
1456    fn dispatch_coordinator_config_serde_roundtrip() {
1457        let config = dispatch_test_config(vec![dispatch_endpoint(
1458            "openai",
1459            "gpt-4o",
1460            ModelCapabilities::for_model("gpt-4o"),
1461        )]);
1462        let json = serde_json::to_string(&config).unwrap();
1463        let decoded: CoordinatorConfig = serde_json::from_str(&json).unwrap();
1464        assert_eq!(decoded.providers.len(), config.providers.len());
1465        assert_eq!(decoded.fallback_chain, config.fallback_chain);
1466        assert_eq!(decoded.load_balance, config.load_balance);
1467    }
1468
1469    #[test]
1470    fn dispatch_plan_serde_roundtrip() {
1471        let plan = DispatchPlan {
1472            primary: RouteDecision {
1473                provider_id: "openai".into(),
1474                model: "gpt-4o".into(),
1475                reason: "primary".into(),
1476            },
1477            fallbacks: vec![RouteDecision {
1478                provider_id: "ollama".into(),
1479                model: "llama3".into(),
1480                reason: "fallback".into(),
1481            }],
1482        };
1483        serde_roundtrip(&plan);
1484    }
1485
1486    #[test]
1487    fn dispatch_route_decision_serde_roundtrip() {
1488        let decision = RouteDecision {
1489            provider_id: "openai".into(),
1490            model: "gpt-4o".into(),
1491            reason: "best score".into(),
1492        };
1493        serde_roundtrip(&decision);
1494    }
1495
1496    #[test]
1497    fn dispatch_load_balance_strategy_serde_roundtrip() {
1498        serde_roundtrip(&LoadBalanceStrategy::LeastLoaded);
1499        serde_roundtrip(&LoadBalanceStrategy::Affinity);
1500    }
1501
1502    #[test]
1503    fn dispatch_route_request_picks_highest_scoring_provider() {
1504        let mut config = dispatch_test_config(vec![
1505            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1506            dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1507        ]);
1508        config.requirements = CapabilityRequirements::for_agent();
1509        let mut state = DispatchState::default();
1510        let route = route_request(&config, &mut state, &[]).unwrap();
1511        assert_eq!(route.provider_id, "openai");
1512    }
1513
1514    #[test]
1515    fn dispatch_route_request_filters_by_capability_requirements() {
1516        let mut config = dispatch_test_config(vec![
1517            dispatch_endpoint(
1518                "basic",
1519                "basic",
1520                ModelCapabilities {
1521                    supports_tools: false,
1522                    production_ready: true,
1523                    ..Default::default()
1524                },
1525            ),
1526            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1527        ]);
1528        config.requirements = CapabilityRequirements::for_agent();
1529        let mut state = DispatchState::default();
1530        let route = route_request(&config, &mut state, &[]).unwrap();
1531        assert_eq!(route.provider_id, "openai");
1532    }
1533
1534    #[test]
1535    fn dispatch_route_request_no_available_provider() {
1536        let mut config = dispatch_test_config(vec![dispatch_endpoint(
1537            "basic",
1538            "basic",
1539            ModelCapabilities {
1540                supports_tools: false,
1541                ..Default::default()
1542            },
1543        )]);
1544        config.requirements = CapabilityRequirements::for_agent();
1545        let mut state = DispatchState::default();
1546        let err = route_request(&config, &mut state, &[]).unwrap_err();
1547        assert!(matches!(
1548            err,
1549            CoordinatorDispatchError::NoAvailableProvider(_)
1550        ));
1551    }
1552
1553    #[test]
1554    fn dispatch_route_request_excludes_providers() {
1555        let config = dispatch_test_config(vec![
1556            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1557            dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1558        ]);
1559        let mut state = DispatchState::default();
1560        let route = route_request(&config, &mut state, &["openai"]).unwrap();
1561        assert_eq!(route.provider_id, "ollama");
1562    }
1563
1564    #[test]
1565    fn dispatch_round_robin_rotates_providers() {
1566        let mut config = dispatch_test_config(vec![
1567            dispatch_endpoint("a", "m1", ModelCapabilities::default()),
1568            dispatch_endpoint("b", "m2", ModelCapabilities::default()),
1569        ]);
1570        config.load_balance = LoadBalanceStrategy::RoundRobin;
1571        let mut state = DispatchState::default();
1572        let first = route_request(&config, &mut state, &[]).unwrap().provider_id;
1573        let second = route_request(&config, &mut state, &[]).unwrap().provider_id;
1574        let third = route_request(&config, &mut state, &[]).unwrap().provider_id;
1575        assert_ne!(first, second);
1576        assert_eq!(first, third);
1577    }
1578
1579    #[test]
1580    fn dispatch_least_loaded_prefers_lower_in_flight() {
1581        let mut config = dispatch_test_config(vec![
1582            {
1583                let mut p = dispatch_endpoint("busy", "m1", ModelCapabilities::default());
1584                p.in_flight_requests = 50;
1585                p
1586            },
1587            {
1588                let mut p = dispatch_endpoint("idle", "m2", ModelCapabilities::default());
1589                p.in_flight_requests = 1;
1590                p
1591            },
1592        ]);
1593        config.load_balance = LoadBalanceStrategy::LeastLoaded;
1594        let mut state = DispatchState::default();
1595        let route = route_request(&config, &mut state, &[]).unwrap();
1596        assert_eq!(route.provider_id, "idle");
1597    }
1598
1599    #[test]
1600    fn dispatch_affinity_prefers_matching_group() {
1601        let mut config = dispatch_test_config(vec![
1602            {
1603                let mut p = dispatch_endpoint("a", "m1", ModelCapabilities::default());
1604                p.affinity_group = Some("tenant-1".into());
1605                p
1606            },
1607            {
1608                let mut p = dispatch_endpoint("b", "m2", ModelCapabilities::default());
1609                p.affinity_group = Some("tenant-2".into());
1610                p
1611            },
1612        ]);
1613        config.load_balance = LoadBalanceStrategy::Affinity;
1614        config.affinity_key = Some("tenant-2".into());
1615        let mut state = DispatchState::default();
1616        let route = route_request(&config, &mut state, &[]).unwrap();
1617        assert_eq!(route.provider_id, "b");
1618    }
1619
1620    #[test]
1621    fn dispatch_affinity_session_overrides_config_key() {
1622        let mut config = dispatch_test_config(vec![
1623            {
1624                let mut p = dispatch_endpoint("a", "m1", ModelCapabilities::default());
1625                p.affinity_group = Some("tenant-1".into());
1626                p
1627            },
1628            {
1629                let mut p = dispatch_endpoint("b", "m2", ModelCapabilities::default());
1630                p.affinity_group = Some("tenant-2".into());
1631                p
1632            },
1633        ]);
1634        config.load_balance = LoadBalanceStrategy::Affinity;
1635        config.affinity_key = Some("tenant-2".into());
1636        let mut state = DispatchState {
1637            session_affinity: Some("tenant-1".into()),
1638            ..Default::default()
1639        };
1640        let route = route_request(&config, &mut state, &[]).unwrap();
1641        assert_eq!(route.provider_id, "a");
1642    }
1643
1644    #[test]
1645    fn dispatch_select_fallback_follows_chain_order() {
1646        let mut config = dispatch_test_config(vec![
1647            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1648            dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1649        ]);
1650        config.fallback_chain = vec!["ollama".into(), "openai".into()];
1651        let plan = DispatchPlan {
1652            primary: RouteDecision {
1653                provider_id: "openai".into(),
1654                model: "gpt-4o".into(),
1655                reason: "primary".into(),
1656            },
1657            fallbacks: vec![],
1658        };
1659        let next = select_fallback(&config, &plan, "openai", &[])
1660            .unwrap()
1661            .unwrap();
1662        assert_eq!(next.provider_id, "ollama");
1663    }
1664
1665    #[test]
1666    fn dispatch_select_fallback_skips_already_tried() {
1667        let config = dispatch_test_config(vec![
1668            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1669            dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1670        ]);
1671        let plan = DispatchPlan {
1672            primary: RouteDecision {
1673                provider_id: "openai".into(),
1674                model: "gpt-4o".into(),
1675                reason: "primary".into(),
1676            },
1677            fallbacks: vec![RouteDecision {
1678                provider_id: "ollama".into(),
1679                model: "llama3".into(),
1680                reason: "first fallback".into(),
1681            }],
1682        };
1683        assert!(
1684            select_fallback(&config, &plan, "ollama", &[])
1685                .unwrap()
1686                .is_none()
1687        );
1688    }
1689
1690    #[test]
1691    fn dispatch_select_fallback_all_failed_surfaces_errors() {
1692        let config = dispatch_test_config(vec![dispatch_endpoint(
1693            "only",
1694            "m",
1695            ModelCapabilities::default(),
1696        )]);
1697        let plan = DispatchPlan {
1698            primary: RouteDecision {
1699                provider_id: "only".into(),
1700                model: "m".into(),
1701                reason: "primary".into(),
1702            },
1703            fallbacks: vec![],
1704        };
1705        let err = select_fallback(
1706            &config,
1707            &plan,
1708            "only",
1709            &["timeout".into(), "rate limited".into()],
1710        )
1711        .unwrap_err();
1712        assert!(matches!(err, CoordinatorDispatchError::AllFailed(_)));
1713        if let CoordinatorDispatchError::AllFailed(errors) = err {
1714            assert_eq!(errors.len(), 2);
1715        }
1716    }
1717
1718    #[test]
1719    fn dispatch_build_dispatch_plan_includes_fallbacks() {
1720        let mut config = dispatch_test_config(vec![
1721            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1722            dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1723        ]);
1724        config.fallback_chain = vec!["ollama".into()];
1725        let mut state = DispatchState::default();
1726        let plan = build_dispatch_plan(&config, &mut state).unwrap();
1727        assert!(!plan.fallbacks.is_empty());
1728        assert_eq!(plan.fallbacks[0].provider_id, "ollama");
1729    }
1730
1731    #[test]
1732    fn dispatch_parse_coordinator_response_valid() {
1733        let json = r#"{
1734            "primary": {"provider_id":"openai","model":"gpt-4o","reason":"best"},
1735            "fallbacks": [{"provider_id":"ollama","model":"llama3","reason":"backup"}]
1736        }"#;
1737        let plan = parse_coordinator_response(json).unwrap();
1738        assert_eq!(plan.primary.provider_id, "openai");
1739        assert_eq!(plan.fallbacks.len(), 1);
1740    }
1741
1742    #[test]
1743    fn dispatch_parse_coordinator_response_missing_primary() {
1744        let err = parse_coordinator_response(r#"{"fallbacks":[]}"#).unwrap_err();
1745        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1746    }
1747
1748    #[test]
1749    fn dispatch_parse_coordinator_response_invalid_json() {
1750        let err = parse_coordinator_response("not-json").unwrap_err();
1751        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1752    }
1753
1754    #[test]
1755    fn dispatch_parse_coordinator_response_invalid_fallbacks_type() {
1756        let err = parse_coordinator_response(
1757            r#"{"primary":{"provider_id":"a","model":"m","reason":"r"},"fallbacks":"nope"}"#,
1758        )
1759        .unwrap_err();
1760        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1761    }
1762
1763    #[test]
1764    fn dispatch_validate_config_empty_providers() {
1765        let config = CoordinatorConfig {
1766            providers: vec![],
1767            fallback_chain: vec![],
1768            load_balance: LoadBalanceStrategy::RoundRobin,
1769            requirements: CapabilityRequirements::default(),
1770            affinity_key: None,
1771        };
1772        let err = validate_coordinator_config(&config).unwrap_err();
1773        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1774    }
1775
1776    #[test]
1777    fn dispatch_validate_config_unknown_fallback() {
1778        let config = CoordinatorConfig {
1779            providers: vec![dispatch_endpoint(
1780                "openai",
1781                "gpt-4o",
1782                ModelCapabilities::default(),
1783            )],
1784            fallback_chain: vec!["missing".into()],
1785            load_balance: LoadBalanceStrategy::RoundRobin,
1786            requirements: CapabilityRequirements::default(),
1787            affinity_key: None,
1788        };
1789        let err = validate_coordinator_config(&config).unwrap_err();
1790        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1791    }
1792
1793    #[test]
1794    fn dispatch_validate_config_duplicate_ids() {
1795        let config = CoordinatorConfig {
1796            providers: vec![
1797                dispatch_endpoint("dup", "m1", ModelCapabilities::default()),
1798                dispatch_endpoint("dup", "m2", ModelCapabilities::default()),
1799            ],
1800            fallback_chain: vec![],
1801            load_balance: LoadBalanceStrategy::RoundRobin,
1802            requirements: CapabilityRequirements::default(),
1803            affinity_key: None,
1804        };
1805        let err = validate_coordinator_config(&config).unwrap_err();
1806        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1807    }
1808
1809    #[test]
1810    fn dispatch_coordinator_dispatch_error_display_no_available() {
1811        let err = CoordinatorDispatchError::NoAvailableProvider("none left".into());
1812        assert!(err.to_string().contains("no available provider"));
1813        assert!(err.to_string().contains("none left"));
1814    }
1815
1816    #[test]
1817    fn dispatch_coordinator_dispatch_error_display_all_failed() {
1818        let err = CoordinatorDispatchError::AllFailed(vec!["e1".into(), "e2".into()]);
1819        let s = err.to_string();
1820        assert!(s.contains("all providers failed"));
1821        assert!(s.contains("e1"));
1822    }
1823
1824    #[test]
1825    fn dispatch_coordinator_dispatch_error_display_invalid_config() {
1826        let err = CoordinatorDispatchError::InvalidConfig("bad chain".into());
1827        assert!(err.to_string().contains("invalid coordinator config"));
1828    }
1829
1830    #[test]
1831    fn dispatch_route_decision_display() {
1832        let decision = RouteDecision {
1833            provider_id: "openai".into(),
1834            model: "gpt-4o".into(),
1835            reason: "best".into(),
1836        };
1837        let s = decision.to_string();
1838        assert!(s.contains("openai"));
1839        assert!(s.contains("gpt-4o"));
1840    }
1841
1842    #[test]
1843    fn dispatch_plan_display() {
1844        let plan = DispatchPlan {
1845            primary: RouteDecision {
1846                provider_id: "openai".into(),
1847                model: "gpt-4o".into(),
1848                reason: "primary".into(),
1849            },
1850            fallbacks: vec![RouteDecision {
1851                provider_id: "ollama".into(),
1852                model: "llama3".into(),
1853                reason: "fb".into(),
1854            }],
1855        };
1856        let s = plan.to_string();
1857        assert!(s.contains("openai"));
1858        assert!(s.contains("ollama"));
1859    }
1860
1861    #[test]
1862    fn dispatch_load_balance_strategy_display() {
1863        assert_eq!(LoadBalanceStrategy::RoundRobin.to_string(), "round_robin");
1864        assert_eq!(LoadBalanceStrategy::LeastLoaded.to_string(), "least_loaded");
1865        assert_eq!(LoadBalanceStrategy::Affinity.to_string(), "affinity");
1866    }
1867
1868    #[test]
1869    fn dispatch_coordinator_config_clone() {
1870        let config = dispatch_test_config(vec![dispatch_endpoint(
1871            "openai",
1872            "gpt-4o",
1873            ModelCapabilities::default(),
1874        )]);
1875        let cloned = config.clone();
1876        assert_eq!(config.providers.len(), cloned.providers.len());
1877        assert_eq!(config.fallback_chain, cloned.fallback_chain);
1878    }
1879
1880    #[test]
1881    fn dispatch_dispatch_state_clone_default() {
1882        let state = DispatchState::default();
1883        let cloned = state.clone();
1884        assert_eq!(state, cloned);
1885        assert_eq!(state.round_robin_cursor, 0);
1886    }
1887
1888    #[test]
1889    fn dispatch_provider_endpoint_clone_debug() {
1890        let endpoint = dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::default());
1891        let cloned = endpoint.clone();
1892        assert_eq!(endpoint, cloned);
1893        assert!(format!("{endpoint:?}").contains("openai"));
1894    }
1895
1896    #[test]
1897    fn dispatch_route_decision_clone_eq() {
1898        let a = RouteDecision {
1899            provider_id: "a".into(),
1900            model: "m".into(),
1901            reason: "r".into(),
1902        };
1903        assert_eq!(a, a.clone());
1904    }
1905
1906    #[test]
1907    fn dispatch_plan_clone_eq() {
1908        let plan = DispatchPlan {
1909            primary: RouteDecision {
1910                provider_id: "a".into(),
1911                model: "m".into(),
1912                reason: "r".into(),
1913            },
1914            fallbacks: vec![],
1915        };
1916        assert_eq!(plan, plan.clone());
1917    }
1918
1919    #[test]
1920    fn dispatch_coordinator_dispatch_error_clone_eq() {
1921        let err = CoordinatorDispatchError::InvalidConfig("x".into());
1922        assert_eq!(err, err.clone());
1923    }
1924
1925    #[test]
1926    fn dispatch_build_dispatch_plan_invalid_config_propagates() {
1927        let config = CoordinatorConfig {
1928            providers: vec![],
1929            fallback_chain: vec![],
1930            load_balance: LoadBalanceStrategy::RoundRobin,
1931            requirements: CapabilityRequirements::default(),
1932            affinity_key: None,
1933        };
1934        let err = build_dispatch_plan(&config, &mut DispatchState::default()).unwrap_err();
1935        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1936    }
1937
1938    #[test]
1939    fn dispatch_route_request_invalid_config_before_routing() {
1940        let config = CoordinatorConfig {
1941            providers: vec![],
1942            fallback_chain: vec![],
1943            load_balance: LoadBalanceStrategy::RoundRobin,
1944            requirements: CapabilityRequirements::default(),
1945            affinity_key: None,
1946        };
1947        let err = route_request(&config, &mut DispatchState::default(), &[]).unwrap_err();
1948        assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1949    }
1950
1951    #[test]
1952    fn dispatch_select_fallback_respects_capability_on_chain() {
1953        let mut config = dispatch_test_config(vec![
1954            dispatch_endpoint(
1955                "no-tools",
1956                "basic",
1957                ModelCapabilities {
1958                    supports_tools: false,
1959                    ..Default::default()
1960                },
1961            ),
1962            dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1963        ]);
1964        config.fallback_chain = vec!["no-tools".into(), "openai".into()];
1965        config.requirements = CapabilityRequirements::for_agent();
1966        let plan = DispatchPlan {
1967            primary: RouteDecision {
1968                provider_id: "openai".into(),
1969                model: "gpt-4o".into(),
1970                reason: "primary".into(),
1971            },
1972            fallbacks: vec![],
1973        };
1974        assert!(
1975            select_fallback(&config, &plan, "openai", &[])
1976                .unwrap()
1977                .is_none()
1978        );
1979    }
1980    #[test]
1981    fn test_message_to_role_content() {
1982        let msg = ConversationMessage::user("Hello");
1983        let (role, content) = msg.to_role_content();
1984        assert_eq!(role, "user");
1985        assert_eq!(content, "Hello");
1986
1987        let msg = ConversationMessage::system("System prompt");
1988        let (role, content) = msg.to_role_content();
1989        assert_eq!(role, "system");
1990        assert_eq!(content, "System prompt");
1991    }
1992}