Skip to main content

lc_chains/
base.rs

1// lc-chains/src/base.rs
2//! Chain base trait.
3
4use async_trait::async_trait;
5use futures_util::{stream, Stream, StreamExt};
6use lc_callbacks::{CallbackManager, RunTree, RunType};
7use lc_core::runnables::RunnableConfig;
8use lc_schema::Message;
9use lc_shared::document::Document;
10use serde_json::{json, Value};
11use std::collections::HashMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16/// Chain error type.
17#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum ChainError {
20    /// Missing input.
21    #[error("Missing input: {0}")]
22    MissingInput(String),
23
24    /// Input error (present but malformed — e.g. a document that fails to deserialize).
25    #[error("Input error: {0}")]
26    InputError(String),
27
28    /// Output error.
29    #[error("Output error: {0}")]
30    OutputError(String),
31
32    /// Execution error.
33    #[error("Execution error: {0}")]
34    ExecutionError(String),
35
36    /// Stream error.
37    #[error("Stream error: {0}")]
38    StreamError(String),
39
40    /// Other error.
41    #[error("Chain error: {0}")]
42    Other(String),
43
44    /// Nested (sub-chain / LLM) execution error, preserving the original error
45    /// chain.
46    ///
47    /// P2-1: composite chains (SequentialChain / RouterChain) wrap sub-chain or
48    /// LLM failures in this variant so the underlying error stays inspectable
49    /// via `source()` (e.g. downcast back to a concrete `ChainError` variant)
50    /// instead of being flattened into a string by `format!`.
51    #[error("{context}: {source}")]
52    Nested {
53        /// Human-readable context describing which step/chain failed.
54        context: String,
55        /// The underlying error, preserved for chaining.
56        #[source]
57        source: Box<dyn std::error::Error + Send + Sync>,
58    },
59}
60
61/// Chain execution result.
62pub type ChainResult = HashMap<String, Value>;
63
64/// Stream output item: token-by-token output.
65#[derive(Debug, Clone)]
66pub struct StreamToken {
67    /// Token text.
68    pub token: String,
69    /// Whether this is the final token.
70    pub is_final: bool,
71}
72
73/// Chain stream output type.
74pub type ChainStream = Pin<Box<dyn Stream<Item = Result<StreamToken, ChainError>> + Send>>;
75
76/// Convert memory variables (from `BaseMemory::load_memory_variables`) into a
77/// message list for LLM consumption.
78///
79/// Memory implementations produce two shapes under their variable keys:
80/// - `Value::Array` of serialized [`Message`] objects (`return_messages = true`)
81/// - `Value::String` rendered history (return_messages = false, summary, vectorstore)
82///
83/// String-form history is wrapped as a `System` message, matching the convention
84/// used by `ConversationSummaryMemory` (summary.rs wraps its buffer as System).
85pub(crate) fn variables_to_messages(vars: &HashMap<String, Value>) -> Vec<Message> {
86    // P2-1: converge on the shared converter in lc-memory to avoid the two
87    // implementations drifting apart.
88    lc_memory::memory_variables_to_messages(vars)
89}
90
91/// Deserialize a `documents` input array into `Vec<Document>`, failing loudly
92/// when any item cannot be parsed instead of silently dropping it.
93///
94/// P1-2: replaces the old `filter_map(|v| serde_json::from_value(v.clone()).ok())`
95/// pattern, which hid malformed entries from the caller. A missing `documents`
96/// key, or a present-but-malformed array, yields [`ChainError::MissingInput`] /
97/// [`ChainError::InputError`] respectively.
98pub(crate) fn documents_from_input(value: Option<&Value>) -> Result<Vec<Document>, ChainError> {
99    let arr = value
100        .and_then(|v| v.as_array())
101        .ok_or_else(|| ChainError::MissingInput("documents".to_string()))?;
102
103    let mut docs = Vec::with_capacity(arr.len());
104    let mut failed = 0usize;
105    for item in arr {
106        match serde_json::from_value::<Document>(item.clone()) {
107            Ok(doc) => docs.push(doc),
108            Err(_) => failed += 1,
109        }
110    }
111    if failed > 0 {
112        return Err(ChainError::InputError(format!(
113            "document deserialization failed: {failed} of {} document(s) lost",
114            arr.len()
115        )));
116    }
117    Ok(docs)
118}
119
120/// Serialize documents for the `source_documents` output key, failing loudly
121/// instead of silently inserting `Value::Null` entries.
122pub(crate) fn documents_to_values(documents: &[Document]) -> Result<Vec<Value>, ChainError> {
123    documents
124        .iter()
125        .map(|doc| {
126            serde_json::to_value(doc)
127                .map_err(|e| ChainError::Other(format!("failed to serialize document: {e}")))
128        })
129        .collect()
130}
131
132/// Base Chain trait.
133///
134/// Chain is LangChain's core abstraction, representing a sequence of operations.
135#[async_trait]
136pub trait BaseChain: Send + Sync {
137    /// Get input keys.
138    fn input_keys(&self) -> Vec<&str>;
139
140    /// Get output keys.
141    fn output_keys(&self) -> Vec<&str>;
142
143    /// Execute the Chain.
144    ///
145    /// # Arguments
146    /// * `inputs` - Input parameter dictionary
147    ///
148    /// # Returns
149    /// Output result dictionary
150    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError>;
151
152    /// Execute the Chain with a RunnableConfig.
153    ///
154    /// This method propagates callbacks through the chain execution.
155    /// The default implementation wraps `invoke()` with `on_chain_start` /
156    /// `on_chain_end` (or `on_chain_error`) dispatch, so config.callbacks are
157    /// never silently dropped — even for chains that don't override this method.
158    ///
159    /// Chains that want to propagate LLM callbacks (on_llm_start/end) or thread
160    /// config into sub-chains (SequentialChain / RouterChain) override this method.
161    async fn invoke_with_config(
162        &self,
163        inputs: HashMap<String, Value>,
164        config: Option<RunnableConfig>,
165    ) -> Result<ChainResult, ChainError> {
166        run_chain_with_callbacks(self.name(), inputs, config, |inputs| async move {
167            self.invoke(inputs).await
168        })
169        .await
170    }
171
172    /// Stream execute the Chain -- token by token output.
173    ///
174    /// Default implementation wraps the invoke result as a single-element stream.
175    /// Chains that support LLM streaming (LLMChain / ConversationChain) should
176    /// override this method, calling `BaseChatModel::stream_chat` internally.
177    ///
178    /// # Arguments
179    /// * `inputs` - Input parameter dictionary
180    ///
181    /// # Returns
182    /// Token stream
183    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
184        // Default: wrap invoke result as single-element stream
185        let result = self.invoke(inputs).await?;
186        // P2-2: a chain that produces no string output now fails loudly instead
187        // of silently streaming a single empty token (`unwrap_or("")`).
188        let output_text = result
189            .values()
190            .next()
191            .and_then(|v| v.as_str())
192            .ok_or_else(|| {
193                ChainError::OutputError("chain produced no string output to stream".to_string())
194            })?
195            .to_string();
196        let stream = futures_util::stream::once(async move {
197            Ok(StreamToken {
198                token: output_text,
199                is_final: true,
200            })
201        });
202        Ok(Box::pin(stream))
203    }
204
205    /// Stream execute the Chain with a RunnableConfig.
206    ///
207    /// The default implementation wraps `stream()` with `on_chain_start` and
208    /// dispatches `on_chain_end` (or `on_chain_error`) once the token stream
209    /// completes, so config.callbacks are never silently dropped.
210    async fn stream_with_config(
211        &self,
212        inputs: HashMap<String, Value>,
213        config: Option<RunnableConfig>,
214    ) -> Result<ChainStream, ChainError> {
215        stream_chain_with_callbacks(self.name(), inputs, config, |inputs| async move {
216            self.stream(inputs).await
217        })
218        .await
219    }
220
221    /// Validate inputs.
222    fn validate_inputs(&self, inputs: &HashMap<String, Value>) -> Result<(), ChainError> {
223        for key in self.input_keys() {
224            if !inputs.contains_key(key) {
225                return Err(ChainError::MissingInput(key.to_string()));
226            }
227        }
228        Ok(())
229    }
230
231    /// Get Chain name.
232    fn name(&self) -> &str {
233        "chain"
234    }
235}
236
237/// Run a chain body wrapped in `on_chain_start` → body → `on_chain_end` /
238/// `on_chain_error` callback dispatch.
239///
240/// Shared by the default `invoke_with_config` and by composite chains
241/// (SequentialChain / RouterChain) that override it to thread `config` into
242/// their sub-chains. Callbacks are only dispatched when `config` carries one;
243/// otherwise this is a plain pass-through so the common path pays no tracing.
244pub(crate) async fn run_chain_with_callbacks<F, Fut>(
245    name: &str,
246    inputs: HashMap<String, Value>,
247    config: Option<RunnableConfig>,
248    body: F,
249) -> Result<ChainResult, ChainError>
250where
251    F: FnOnce(HashMap<String, Value>) -> Fut,
252    Fut: Future<Output = Result<ChainResult, ChainError>> + Send,
253{
254    let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
255    let mut run = RunTree::new(name, RunType::Chain, json!({ "inputs": inputs }));
256
257    if let Some(ref cb) = callbacks {
258        cb.dispatch_chain_start(&run, &run.inputs).await;
259    }
260
261    let result = body(inputs).await;
262
263    match result {
264        Ok(output) => {
265            run.end(json!({ "output": output }));
266            if let Some(ref cb) = callbacks {
267                cb.dispatch_chain_end(&run, &json!({ "output": output }))
268                    .await;
269            }
270            Ok(output)
271        }
272        Err(e) => {
273            let msg = e.to_string();
274            run.end_with_error(msg.clone());
275            if let Some(ref cb) = callbacks {
276                cb.dispatch_chain_error(&run, &msg).await;
277            }
278            Err(e)
279        }
280    }
281}
282
283/// Stream a chain body wrapped in `on_chain_start` dispatch, ending the run
284/// (and dispatching `on_chain_end` / `on_chain_error`) once the token stream
285/// completes or fails.
286///
287/// Shared by the default `stream_with_config` and by composite chains that
288/// override it to thread `config` into their sub-chains.
289pub(crate) async fn stream_chain_with_callbacks<F, Fut>(
290    name: &str,
291    inputs: HashMap<String, Value>,
292    config: Option<RunnableConfig>,
293    body: F,
294) -> Result<ChainStream, ChainError>
295where
296    F: FnOnce(HashMap<String, Value>) -> Fut,
297    Fut: Future<Output = Result<ChainStream, ChainError>> + Send,
298{
299    let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
300    let mut run = RunTree::new(name, RunType::Chain, json!({ "inputs": inputs }));
301
302    if let Some(ref cb) = callbacks {
303        cb.dispatch_chain_start(&run, &run.inputs).await;
304    }
305
306    let stream = match body(inputs).await {
307        Ok(s) => s,
308        Err(e) => {
309            let msg = e.to_string();
310            run.end_with_error(msg.clone());
311            if let Some(ref cb) = callbacks {
312                cb.dispatch_chain_error(&run, &msg).await;
313            }
314            return Err(e);
315        }
316    };
317
318    Ok(Box::pin(end_stream_on_completion(stream, run, callbacks)))
319}
320
321/// Wrap a chain token stream so the RunTree is ended and `on_chain_end` /
322/// `on_chain_error` dispatched once the stream completes or errors.
323fn end_stream_on_completion(
324    inner: ChainStream,
325    run: RunTree,
326    callbacks: Option<Arc<CallbackManager>>,
327) -> impl Stream<Item = Result<StreamToken, ChainError>> + Send {
328    stream::unfold(Some((inner, run, callbacks)), |state| async move {
329        let (mut inner, run, callbacks) = match state {
330            Some(s) => s,
331            None => return None,
332        };
333        match inner.next().await {
334            Some(Ok(token)) => Some((Ok(token), Some((inner, run, callbacks)))),
335            Some(Err(e)) => {
336                let msg = e.to_string();
337                let mut run = run;
338                run.end_with_error(msg.clone());
339                if let Some(cb) = callbacks {
340                    cb.dispatch_chain_error(&run, &msg).await;
341                }
342                Some((Err(e), None))
343            }
344            None => {
345                let mut run = run;
346                run.end(json!({ "output": null }));
347                if let Some(cb) = callbacks {
348                    cb.dispatch_chain_end(&run, &json!({ "output": null }))
349                        .await;
350                }
351                None
352            }
353        }
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::error::Error;
361
362    #[test]
363    fn test_chain_error_display() {
364        let error = ChainError::MissingInput("test".to_string());
365        assert!(error.to_string().contains("Missing input"));
366
367        let error = ChainError::ExecutionError("test".to_string());
368        assert!(error.to_string().contains("Execution error"));
369    }
370
371    #[test]
372    fn test_chain_error_all_variants() {
373        let err = ChainError::MissingInput("key".to_string());
374        assert!(err.to_string().contains("key"));
375
376        let err = ChainError::OutputError("bad".to_string());
377        assert!(err.to_string().contains("bad"));
378
379        let err = ChainError::ExecutionError("fail".to_string());
380        assert!(err.to_string().contains("fail"));
381
382        let err = ChainError::StreamError("broken".to_string());
383        assert!(err.to_string().contains("broken"));
384
385        let err = ChainError::Other("misc".to_string());
386        assert!(err.to_string().contains("misc"));
387    }
388
389    /// P2-1: `Nested` preserves the original error chain — `source()` must
390    /// downcast back to the concrete `ChainError` variant instead of a
391    /// flattened string.
392    #[test]
393    fn test_chain_error_nested_preserves_source() {
394        let inner = ChainError::MissingInput("text".to_string());
395        let nested = ChainError::Nested {
396            context: "Step 0 (echo) execution failed".to_string(),
397            source: Box::new(inner),
398        };
399        assert!(nested
400            .to_string()
401            .contains("Step 0 (echo) execution failed"));
402        assert!(nested.to_string().contains("Missing input"));
403
404        let source = nested.source().expect("Nested must carry a source");
405        let downcast = source.downcast_ref::<ChainError>();
406        assert!(
407            matches!(downcast, Some(ChainError::MissingInput(k)) if k == "text"),
408            "source should downcast back to the original variant, got {downcast:?}"
409        );
410    }
411
412    #[test]
413    fn test_stream_token_debug() {
414        let token = StreamToken {
415            token: "hello".to_string(),
416            is_final: false,
417        };
418        assert!(format!("{:?}", token).contains("hello"));
419    }
420
421    /// P2-2: the default stream fails loudly when the chain produces a
422    /// non-string output instead of silently emitting a single empty token
423    /// (the old `unwrap_or("")`).
424    #[tokio::test]
425    async fn test_default_stream_errors_on_non_string_output() {
426        struct NonStringChain;
427        #[async_trait]
428        impl BaseChain for NonStringChain {
429            fn input_keys(&self) -> Vec<&str> {
430                vec![]
431            }
432            fn output_keys(&self) -> Vec<&str> {
433                vec!["count"]
434            }
435            async fn invoke(
436                &self,
437                _inputs: HashMap<String, Value>,
438            ) -> Result<ChainResult, ChainError> {
439                let mut result = HashMap::new();
440                result.insert("count".to_string(), json!(3));
441                Ok(result)
442            }
443        }
444
445        let chain = NonStringChain;
446        let err = match chain.stream(HashMap::new()).await {
447            Ok(_) => panic!("expected an OutputError"),
448            Err(e) => e,
449        };
450        assert!(
451            matches!(err, ChainError::OutputError(_)),
452            "expected OutputError, got {err:?}"
453        );
454    }
455
456    #[test]
457    fn test_validate_inputs_pass() {
458        struct PassthroughChain;
459        #[async_trait]
460        impl BaseChain for PassthroughChain {
461            fn input_keys(&self) -> Vec<&str> {
462                vec!["input"]
463            }
464            fn output_keys(&self) -> Vec<&str> {
465                vec!["output"]
466            }
467            async fn invoke(
468                &self,
469                inputs: HashMap<String, Value>,
470            ) -> Result<ChainResult, ChainError> {
471                Ok(inputs)
472            }
473        }
474
475        let chain = PassthroughChain;
476        let mut inputs = HashMap::new();
477        inputs.insert("input".to_string(), Value::String("test".to_string()));
478        assert!(chain.validate_inputs(&inputs).is_ok());
479    }
480
481    #[test]
482    fn test_validate_inputs_missing_key() {
483        struct PassthroughChain;
484        #[async_trait]
485        impl BaseChain for PassthroughChain {
486            fn input_keys(&self) -> Vec<&str> {
487                vec!["input"]
488            }
489            fn output_keys(&self) -> Vec<&str> {
490                vec!["output"]
491            }
492            async fn invoke(
493                &self,
494                _inputs: HashMap<String, Value>,
495            ) -> Result<ChainResult, ChainError> {
496                Ok(HashMap::new())
497            }
498        }
499
500        let chain = PassthroughChain;
501        let inputs = HashMap::new();
502        assert!(chain.validate_inputs(&inputs).is_err());
503    }
504
505    #[test]
506    fn test_default_chain_name() {
507        struct MyChain;
508        #[async_trait]
509        impl BaseChain for MyChain {
510            fn input_keys(&self) -> Vec<&str> {
511                vec![]
512            }
513            fn output_keys(&self) -> Vec<&str> {
514                vec![]
515            }
516            async fn invoke(
517                &self,
518                _inputs: HashMap<String, Value>,
519            ) -> Result<ChainResult, ChainError> {
520                Ok(HashMap::new())
521            }
522        }
523        let chain = MyChain;
524        assert_eq!(chain.name(), "chain");
525    }
526}