Skip to main content

lc_chains/router_chain/
llm.rs

1// lc-chains/src/router_chain/llm.rs
2//! LLM-based routing chain.
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use lc_core::runnables::RunnableConfig;
9use lc_core::BaseChatModel;
10use lc_providers::{wrap_chat_model, ProviderError};
11use lc_schema::Message;
12use serde_json::Value;
13
14use crate::base::{
15    run_chain_with_callbacks, stream_chain_with_callbacks, BaseChain, ChainError, ChainResult,
16    ChainStream,
17};
18use crate::BoxedChatModel;
19
20use super::destination::RouteDestination;
21use super::{route_tool, RouteDecision};
22
23/// LLM Router Chain
24///
25/// Uses an LLM to intelligently determine the routing destination.
26pub struct LLMRouterChain {
27    /// LLM used for routing decisions.
28    llm: BoxedChatModel,
29
30    /// Route destinations.
31    destinations: Vec<RouteDestination>,
32
33    /// Default Chain.
34    default_chain: Option<Arc<dyn BaseChain>>,
35
36    /// Input key name.
37    input_key: String,
38
39    /// Chain name.
40    name: String,
41
42    /// Whether to print verbose information.
43    verbose: bool,
44}
45
46impl LLMRouterChain {
47    /// Create a new empty [`LLMRouterChain`] with the given LLM.
48    pub fn new<L>(llm: L) -> Self
49    where
50        L: BaseChatModel + Send + Sync + 'static,
51        L::Error: Into<ProviderError>,
52    {
53        Self {
54            llm: wrap_chat_model(llm),
55            destinations: Vec::new(),
56            default_chain: None,
57            input_key: "input".to_string(),
58            name: "llm_router_chain".to_string(),
59            verbose: false,
60        }
61    }
62
63    /// Add a route destination.
64    pub fn add_route(
65        mut self,
66        name: impl Into<String>,
67        description: impl Into<String>,
68        chain: Arc<dyn BaseChain>,
69    ) -> Self {
70        self.destinations
71            .push(RouteDestination::new(name, description, chain));
72        self
73    }
74
75    /// Add a route destination with a keyword list for keyword-based routing.
76    pub fn add_route_with_keywords(
77        mut self,
78        name: impl Into<String>,
79        description: impl Into<String>,
80        chain: Arc<dyn BaseChain>,
81        keywords: Vec<&str>,
82    ) -> Self {
83        self.destinations
84            .push(RouteDestination::new(name, description, chain).with_keywords(keywords));
85        self
86    }
87
88    /// Set the default chain used when no route matches.
89    pub fn with_default(mut self, chain: Arc<dyn BaseChain>) -> Self {
90        self.default_chain = Some(chain);
91        self
92    }
93
94    /// Set the input key.
95    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
96        self.input_key = key.into();
97        self
98    }
99
100    /// Set the chain name.
101    pub fn with_name(mut self, name: impl Into<String>) -> Self {
102        self.name = name.into();
103        self
104    }
105
106    /// Set verbose mode.
107    pub fn with_verbose(mut self, verbose: bool) -> Self {
108        self.verbose = verbose;
109        self
110    }
111
112    /// Get the route destinations.
113    pub fn destinations(&self) -> &[RouteDestination] {
114        &self.destinations
115    }
116
117    /// Get the default chain, if set.
118    pub fn default_chain(&self) -> Option<&Arc<dyn BaseChain>> {
119        self.default_chain.as_ref()
120    }
121
122    /// Build the LLM routing prompt.
123    fn build_router_prompt(&self, input: &str) -> String {
124        let mut prompt =
125            String::from("Based on the user input, select the most appropriate handler.\n\n");
126        prompt.push_str("Available handlers:\n");
127
128        for (i, dest) in self.destinations.iter().enumerate() {
129            prompt.push_str(&format!(
130                "{}. {}: {}\n",
131                i + 1,
132                dest.name(),
133                dest.description()
134            ));
135        }
136
137        prompt.push_str("\nUser input: ");
138        prompt.push_str(input);
139        // P2-5: ask for a structured object rather than a bare name so the
140        // decision (destination + reason) survives parsing reliably.
141        prompt.push_str(
142            "\n\nReturn only the chosen handler as JSON: {\"destination\": \"<handler name>\", \"reason\": \"<why this handler>\"}",
143        );
144
145        prompt
146    }
147
148    /// Use LLM to determine the route (P2-5: structured output).
149    ///
150    /// Binds `route_to_destination` so the provider emits a structured
151    /// `{destination, reason}` tool-call argument instead of free text; the
152    /// first tool call's parsed arguments win. Providers without tool binding
153    /// (or a response without `tool_calls`) fall back to the same call's text,
154    /// parsed leniently by [`RouteDecision::from_text`]. One LLM call, no
155    /// retry.
156    async fn route_with_llm(
157        &self,
158        input: &str,
159        config: Option<RunnableConfig>,
160    ) -> Result<RouteDecision, ChainError> {
161        let prompt = self.build_router_prompt(input);
162
163        let messages = vec![Message::human(&prompt)];
164
165        let map_err = |e| ChainError::Nested {
166            context: "LLM routing call failed".to_string(),
167            source: Box::new(e),
168        };
169
170        let result = match self.llm.bind_tools(vec![route_tool()]) {
171            Some(bound) => bound.chat(messages, config).await.map_err(map_err)?,
172            None => self.llm.chat(messages, config).await.map_err(map_err)?,
173        };
174
175        if let Some(decision) = result
176            .tool_calls
177            .as_ref()
178            .and_then(|calls| calls.first())
179            .and_then(|call| call.parse_arguments::<RouteDecision>().ok())
180        {
181            return Ok(decision);
182        }
183
184        Ok(RouteDecision::from_text(&result.content))
185    }
186
187    /// Find a route destination by name.
188    fn find_destination(&self, name: &str) -> Option<&RouteDestination> {
189        let name_lower = name.to_lowercase();
190        // 1. Exact case-insensitive match
191        if let Some(dest) = self
192            .destinations
193            .iter()
194            .find(|d| d.name().eq_ignore_ascii_case(name))
195        {
196            return Some(dest);
197        }
198        // 2. The LLM result starts or ends with the destination name
199        self.destinations.iter().find(|d| {
200            let d_lower = d.name().to_lowercase();
201            name_lower.starts_with(&d_lower)
202                || name_lower.ends_with(&d_lower)
203                || name_lower
204                    .split_whitespace()
205                    .any(|word| word.eq_ignore_ascii_case(&d_lower))
206        })
207    }
208
209    /// LLM routing takes priority over keyword matching.
210    async fn select_route(
211        &self,
212        input: &str,
213        config: Option<RunnableConfig>,
214    ) -> Result<&RouteDestination, ChainError> {
215        if self.destinations.is_empty() {
216            return Err(ChainError::ExecutionError(
217                "No route destinations configured".to_string(),
218            ));
219        }
220
221        if self.destinations.len() == 1 {
222            return Ok(&self.destinations[0]);
223        }
224
225        // Try LLM routing first (primary strategy).
226        // P1-6: the LLM error/unknown-name is retained rather than swallowed, so
227        // keyword fallback stays as a legitimate safety net but the final error
228        // carries the real routing diagnostics.
229        // P2-5: the LLM now returns a structured decision {destination, reason};
230        // the reason is surfaced in verbose mode only.
231        let llm_note: Option<String> = {
232            let llm_result = self.route_with_llm(input, config).await;
233            match llm_result {
234                Ok(decision) => {
235                    if let Some(reason) = &decision.reason {
236                        if self.verbose {
237                            println!("LLM route reason: {}", reason);
238                        }
239                    }
240                    if let Some(dest) = self.find_destination(&decision.destination) {
241                        return Ok(dest);
242                    }
243                    Some(format!(
244                        "LLM returned an unknown route destination {:?}",
245                        decision.destination
246                    ))
247                }
248                Err(e) => Some(format!("LLM routing call failed: {}", e)),
249            }
250        };
251
252        // Fallback: keyword matching (longest match first)
253        let mut best_match: Option<(&RouteDestination, usize)> = None;
254        for dest in &self.destinations {
255            for keyword in dest.keywords() {
256                if input.contains(keyword) {
257                    let len = keyword.len();
258                    if best_match.is_none() || len > best_match.unwrap().1 {
259                        best_match = Some((dest, len));
260                    }
261                }
262            }
263        }
264        if let Some((dest, _)) = best_match {
265            return Ok(dest);
266        }
267
268        Err(ChainError::ExecutionError(format!(
269            "No matching route destination found ({})",
270            llm_note.unwrap_or_else(|| "LLM and keyword matching both failed".to_string())
271        )))
272    }
273
274    /// Route to a destination/default chain and invoke it, threading `config`
275    /// through `invoke_with_config` (never silently dropping it).
276    async fn route_and_invoke(
277        &self,
278        inputs: HashMap<String, Value>,
279        config: Option<RunnableConfig>,
280    ) -> Result<ChainResult, ChainError> {
281        self.validate_inputs(&inputs)?;
282
283        let input = inputs
284            .get(&self.input_key)
285            .and_then(|v| v.as_str())
286            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
287
288        if self.verbose {
289            println!("\n=== LLMRouterChain execution ===");
290            println!("Input: {}", input);
291            println!("Route destination count: {}", self.destinations.len());
292        }
293
294        let route_result = self.select_route(input, config.clone()).await;
295
296        let chain = match route_result {
297            Ok(dest) => {
298                if self.verbose {
299                    println!("Routed to: {} ({})", dest.name(), dest.description());
300                }
301                dest.chain()
302            }
303            Err(e) => {
304                if let Some(default) = &self.default_chain {
305                    // On routing failure, fall back to the default chain: not silently — log
306                    // an error explaining why, so callers do not mistake the fallback answer
307                    // for a correct route selection.
308                    log::error!(
309                        "routing failed, falling back to default chain (caller may receive an \
310                         answer that does not match the input): {e}"
311                    );
312                    default
313                } else {
314                    return Err(e);
315                }
316            }
317        };
318
319        let result = chain.invoke_with_config(inputs, config).await?;
320
321        if self.verbose {
322            println!("=== LLMRouterChain complete ===\n");
323        }
324
325        Ok(result)
326    }
327
328    /// Route to a destination/default chain and stream it, threading `config`
329    /// through `stream_with_config`.
330    async fn route_and_stream(
331        &self,
332        inputs: HashMap<String, Value>,
333        config: Option<RunnableConfig>,
334    ) -> Result<ChainStream, ChainError> {
335        self.validate_inputs(&inputs)?;
336
337        let input = inputs
338            .get(&self.input_key)
339            .and_then(|v| v.as_str())
340            .ok_or_else(|| ChainError::MissingInput(self.input_key.clone()))?;
341
342        let route_result = self.select_route(input, config.clone()).await;
343
344        let chain = match route_result {
345            Ok(dest) => dest.chain(),
346            Err(e) => {
347                if let Some(default) = &self.default_chain {
348                    default
349                } else {
350                    return Err(e);
351                }
352            }
353        };
354
355        chain.stream_with_config(inputs, config).await
356    }
357}
358
359#[async_trait]
360impl BaseChain for LLMRouterChain {
361    fn input_keys(&self) -> Vec<&str> {
362        vec![&self.input_key]
363    }
364
365    fn output_keys(&self) -> Vec<&str> {
366        let mut seen = std::collections::HashSet::new();
367        let mut result: Vec<&str> = Vec::new();
368
369        for dest in &self.destinations {
370            for key in dest.chain().output_keys() {
371                if seen.insert(key.to_string()) {
372                    result.push(key);
373                }
374            }
375        }
376        if let Some(default) = &self.default_chain {
377            for key in default.output_keys() {
378                if seen.insert(key.to_string()) {
379                    result.push(key);
380                }
381            }
382        }
383
384        if result.is_empty() {
385            vec!["output"]
386        } else {
387            result
388        }
389    }
390
391    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
392        self.route_and_invoke(inputs, None).await
393    }
394
395    /// Execute the Chain with config propagation.
396    ///
397    /// Dispatches this chain's `on_chain_start`/`on_chain_end`, threads
398    /// `config` into the routing LLM call, and into the routed destination
399    /// chain via `invoke_with_config`.
400    async fn invoke_with_config(
401        &self,
402        inputs: HashMap<String, Value>,
403        config: Option<RunnableConfig>,
404    ) -> Result<ChainResult, ChainError> {
405        run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
406            self.route_and_invoke(inputs, config).await
407        })
408        .await
409    }
410
411    /// Stream execution for LLMRouterChain.
412    ///
413    /// The routing LLM call must complete first (to determine the destination),
414    /// then delegates to the selected chain's `stream()` method.
415    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
416        self.route_and_stream(inputs, None).await
417    }
418
419    /// Stream execute the Chain with config propagation.
420    async fn stream_with_config(
421        &self,
422        inputs: HashMap<String, Value>,
423        config: Option<RunnableConfig>,
424    ) -> Result<ChainStream, ChainError> {
425        stream_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
426            self.route_and_stream(inputs, config).await
427        })
428        .await
429    }
430
431    fn name(&self) -> &str {
432        &self.name
433    }
434}