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