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/// Single-pass template substitution shared by LLMChain and the document
133/// chains.
134///
135/// 0.22.0 audit fix (H-C1/H-C2): replaces the old per-key `String::replace`
136/// loop, which re-scanned substituted values — a value containing
137/// `{other_key}` could be silently re-substituted (or not, depending on
138/// HashMap iteration order), enabling injection and nondeterminism. This
139/// walks the template exactly once, mirroring
140/// `lc-prompts::template_parser::{parse_template, format_template}`:
141///
142/// - `{name}` placeholders are looked up in `vars` and substituted; values
143///   are never rescanned.
144/// - Missing variables are left as literal `{name}` text and reported in the
145///   returned `Vec<String>` (deduped, first-appearance order) so callers can
146///   decide to error or keep the literal. Variable names follow lc-prompts
147///   semantics: first char alphabetic (CJK included via `is_alphabetic`) or
148///   `_`, then alphanumeric or `_`.
149/// - `{{` and `}}` escape to literal `{` / `}`.
150pub(crate) fn substitute_template(
151    template: &str,
152    vars: &HashMap<String, String>,
153) -> (String, Vec<String>) {
154    let chars: Vec<char> = template.chars().collect();
155    let n = chars.len();
156    let mut out = String::with_capacity(template.len());
157    let mut missing: Vec<String> = Vec::new();
158    let mut i = 0;
159
160    while i < n {
161        let c = chars[i];
162
163        if c == '{' {
164            // Escaped `{{` → literal `{`
165            if i + 1 < n && chars[i + 1] == '{' {
166                out.push('{');
167                i += 2;
168                continue;
169            }
170
171            // Find the closing `}` for a potential `{name}`
172            let mut j = i + 1;
173            while j < n && chars[j] != '}' {
174                j += 1;
175            }
176            if j < n {
177                let name: String = chars[i + 1..j].iter().collect();
178                if is_valid_template_var_name(&name) {
179                    match vars.get(&name) {
180                        Some(v) => out.push_str(v),
181                        None => {
182                            if !missing.contains(&name) {
183                                missing.push(name.clone());
184                            }
185                            // Leave the placeholder as literal text
186                            out.push('{');
187                            out.push_str(&name);
188                            out.push('}');
189                        }
190                    }
191                    i = j + 1;
192                    continue;
193                }
194            }
195
196            // Not a variable — literal `{`
197            out.push('{');
198            i += 1;
199            continue;
200        }
201
202        if c == '}' {
203            // Escaped `}}` → literal `}`
204            if i + 1 < n && chars[i + 1] == '}' {
205                out.push('}');
206                i += 2;
207                continue;
208            }
209            // Lone `}` is literal
210            out.push('}');
211            i += 1;
212            continue;
213        }
214
215        out.push(c);
216        i += 1;
217    }
218
219    (out, missing)
220}
221
222/// A template variable name starts with a letter or `_`, followed by letters,
223/// digits, `_`. `is_alphabetic`/`is_alphanumeric` also accept CJK characters,
224/// matching `lc-prompts::template_parser::is_valid_var_name`.
225fn is_valid_template_var_name(name: &str) -> bool {
226    let mut chars = name.chars();
227    match chars.next() {
228        Some(c) if c.is_alphabetic() || c == '_' => {}
229        _ => return false,
230    }
231    chars.all(|c| c.is_alphanumeric() || c == '_')
232}
233
234/// Base Chain trait.
235///
236/// Chain is LangChain's core abstraction, representing a sequence of operations.
237#[async_trait]
238pub trait BaseChain: Send + Sync {
239    /// Get input keys.
240    fn input_keys(&self) -> Vec<&str>;
241
242    /// Get output keys.
243    fn output_keys(&self) -> Vec<&str>;
244
245    /// Execute the Chain.
246    ///
247    /// # Arguments
248    /// * `inputs` - Input parameter dictionary
249    ///
250    /// # Returns
251    /// Output result dictionary
252    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError>;
253
254    /// Execute the Chain with a RunnableConfig.
255    ///
256    /// This method propagates callbacks through the chain execution.
257    /// The default implementation wraps `invoke()` with `on_chain_start` /
258    /// `on_chain_end` (or `on_chain_error`) dispatch, so config.callbacks are
259    /// never silently dropped — even for chains that don't override this method.
260    ///
261    /// Chains that want to propagate LLM callbacks (on_llm_start/end) or thread
262    /// config into sub-chains (SequentialChain / RouterChain) override this method.
263    async fn invoke_with_config(
264        &self,
265        inputs: HashMap<String, Value>,
266        config: Option<RunnableConfig>,
267    ) -> Result<ChainResult, ChainError> {
268        run_chain_with_callbacks(self.name(), inputs, config, |inputs| async move {
269            self.invoke(inputs).await
270        })
271        .await
272    }
273
274    /// Stream execute the Chain -- token by token output.
275    ///
276    /// Default implementation wraps the invoke result as a single-element stream.
277    /// Chains that support LLM streaming (LLMChain / ConversationChain) should
278    /// override this method, calling `BaseChatModel::stream_chat` internally.
279    ///
280    /// # Arguments
281    /// * `inputs` - Input parameter dictionary
282    ///
283    /// # Returns
284    /// Token stream
285    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
286        // Default: wrap invoke result as single-element stream
287        let result = self.invoke(inputs).await?;
288        // P2-2: a chain that produces no string output now fails loudly instead
289        // of silently streaming a single empty token (`unwrap_or("")`).
290        //
291        // 0.22.0 audit fix: pick the output deterministically instead of taking
292        // an arbitrary `values().next()` (HashMap order): prefer the chain's
293        // declared output key(s), then the only value when the map has exactly
294        // one entry, then the lexicographically smallest key.
295        let as_str = |v: &Value| v.as_str().map(|s| s.to_string());
296        let output_text = self
297            .output_keys()
298            .iter()
299            .find_map(|k| result.get(*k).and_then(as_str))
300            .or_else(|| {
301                if result.len() == 1 {
302                    result.values().next().and_then(as_str)
303                } else {
304                    result
305                        .keys()
306                        .min()
307                        .and_then(|k| result.get(k).and_then(as_str))
308                }
309            })
310            .ok_or_else(|| {
311                ChainError::OutputError("chain produced no string output to stream".to_string())
312            })?;
313        let stream = futures_util::stream::once(async move {
314            Ok(StreamToken {
315                token: output_text,
316                is_final: true,
317            })
318        });
319        Ok(Box::pin(stream))
320    }
321
322    /// Stream execute the Chain with a RunnableConfig.
323    ///
324    /// The default implementation wraps `stream()` with `on_chain_start` and
325    /// dispatches `on_chain_end` (or `on_chain_error`) once the token stream
326    /// completes, so config.callbacks are never silently dropped.
327    async fn stream_with_config(
328        &self,
329        inputs: HashMap<String, Value>,
330        config: Option<RunnableConfig>,
331    ) -> Result<ChainStream, ChainError> {
332        let output_key = self.output_keys().first().map(|k| (*k).to_string());
333        stream_chain_with_callbacks(
334            self.name(),
335            inputs,
336            config,
337            output_key,
338            |inputs| async move {
339                self.stream(inputs).await
340            },
341        )
342        .await
343    }
344
345    /// Validate inputs.
346    fn validate_inputs(&self, inputs: &HashMap<String, Value>) -> Result<(), ChainError> {
347        for key in self.input_keys() {
348            if !inputs.contains_key(key) {
349                return Err(ChainError::MissingInput(key.to_string()));
350            }
351        }
352        Ok(())
353    }
354
355    /// Get Chain name.
356    fn name(&self) -> &str {
357        "chain"
358    }
359}
360
361/// Run a chain body wrapped in `on_chain_start` → body → `on_chain_end` /
362/// `on_chain_error` callback dispatch.
363///
364/// Shared by the default `invoke_with_config` and by composite chains
365/// (SequentialChain / RouterChain) that override it to thread `config` into
366/// their sub-chains. Callbacks are only dispatched when `config` carries one;
367/// otherwise this is a plain pass-through so the common path pays no tracing.
368pub(crate) async fn run_chain_with_callbacks<F, Fut>(
369    name: &str,
370    inputs: HashMap<String, Value>,
371    config: Option<RunnableConfig>,
372    body: F,
373) -> Result<ChainResult, ChainError>
374where
375    F: FnOnce(HashMap<String, Value>) -> Fut,
376    Fut: Future<Output = Result<ChainResult, ChainError>> + Send,
377{
378    let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
379    let mut run = RunTree::new(name, RunType::Chain, json!({ "inputs": inputs }));
380
381    if let Some(ref cb) = callbacks {
382        cb.dispatch_chain_start(&run, &run.inputs).await;
383    }
384
385    let result = body(inputs).await;
386
387    match result {
388        Ok(output) => {
389            run.end(json!({ "output": output }));
390            if let Some(ref cb) = callbacks {
391                cb.dispatch_chain_end(&run, &json!({ "output": output }))
392                    .await;
393            }
394            Ok(output)
395        }
396        Err(e) => {
397            let msg = e.to_string();
398            run.end_with_error(msg.clone());
399            if let Some(ref cb) = callbacks {
400                cb.dispatch_chain_error(&run, &msg).await;
401            }
402            Err(e)
403        }
404    }
405}
406
407/// Stream a chain body wrapped in `on_chain_start` dispatch, ending the run
408/// (and dispatching `on_chain_end` / `on_chain_error`) once the token stream
409/// completes or fails.
410///
411/// Shared by the default `stream_with_config` and by composite chains that
412/// override it to thread `config` into their sub-chains.
413///
414/// `output_key` names the key under which the accumulated token text is
415/// reported on `on_chain_end` (0.22.0 audit fix: previously the final
416/// dispatch always carried `output: null`).
417pub(crate) async fn stream_chain_with_callbacks<F, Fut>(
418    name: &str,
419    inputs: HashMap<String, Value>,
420    config: Option<RunnableConfig>,
421    output_key: Option<String>,
422    body: F,
423) -> Result<ChainStream, ChainError>
424where
425    F: FnOnce(HashMap<String, Value>) -> Fut,
426    Fut: Future<Output = Result<ChainStream, ChainError>> + Send,
427{
428    let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
429    let mut run = RunTree::new(name, RunType::Chain, json!({ "inputs": inputs }));
430
431    if let Some(ref cb) = callbacks {
432        cb.dispatch_chain_start(&run, &run.inputs).await;
433    }
434
435    let stream = match body(inputs).await {
436        Ok(s) => s,
437        Err(e) => {
438            let msg = e.to_string();
439            run.end_with_error(msg.clone());
440            if let Some(ref cb) = callbacks {
441                cb.dispatch_chain_error(&run, &msg).await;
442            }
443            return Err(e);
444        }
445    };
446
447    Ok(Box::pin(end_stream_on_completion(
448        stream,
449        run,
450        callbacks,
451        output_key,
452    )))
453}
454
455/// Wrap a chain token stream so the RunTree is ended and `on_chain_end` /
456/// `on_chain_error` dispatched once the stream completes or errors.
457///
458/// The accumulated token text is reported under `output_key` on completion
459/// (0.22.0 audit fix: previously always `output: null`).
460fn end_stream_on_completion(
461    inner: ChainStream,
462    run: RunTree,
463    callbacks: Option<Arc<CallbackManager>>,
464    output_key: Option<String>,
465) -> impl Stream<Item = Result<StreamToken, ChainError>> + Send {
466    stream::unfold(
467        Some((inner, run, callbacks, output_key, String::new())),
468        |state| async move {
469            let (mut inner, run, callbacks, output_key, mut accumulated) = match state {
470                Some(s) => s,
471                None => return None,
472            };
473            match inner.next().await {
474                Some(Ok(token)) => {
475                    accumulated.push_str(&token.token);
476                    Some((
477                        Ok(token),
478                        Some((inner, run, callbacks, output_key, accumulated)),
479                    ))
480                }
481                Some(Err(e)) => {
482                    let msg = e.to_string();
483                    let mut run = run;
484                    run.end_with_error(msg.clone());
485                    if let Some(cb) = callbacks {
486                        cb.dispatch_chain_error(&run, &msg).await;
487                    }
488                    Some((Err(e), None))
489                }
490                None => {
491                    let mut run = run;
492                    // 0.22.0 audit fix: report the actual accumulated output
493                    // under the chain's output key instead of `output: null`.
494                    let key = output_key.unwrap_or_else(|| "output".to_string());
495                    let payload = json!({ key: accumulated });
496                    run.end(json!({ "output": payload }));
497                    if let Some(cb) = callbacks {
498                        cb.dispatch_chain_end(&run, &json!({ "output": payload }))
499                            .await;
500                    }
501                    None
502                }
503            }
504        },
505    )
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use std::error::Error;
512
513    /// 0.22.0 audit fix (H-C2): substituted values are never rescanned, so a
514    /// value containing `{key}` cannot be re-substituted.
515    #[test]
516    fn test_substitute_template_no_value_rescan() {
517        let mut vars = HashMap::new();
518        vars.insert("question".to_string(), "value with {summaries} inside".to_string());
519        vars.insert("summaries".to_string(), "SHOULD_NOT_APPEAR".to_string());
520        let (out, missing) = substitute_template("Q: {question} S: {summaries}", &vars);
521        assert_eq!(
522            out,
523            "Q: value with {summaries} inside S: SHOULD_NOT_APPEAR"
524        );
525        assert!(missing.is_empty());
526    }
527
528    /// 0.22.0 audit fix (H-C1): CJK variable names are recognized; missing
529    /// ones are reported instead of silently left behind.
530    #[test]
531    fn test_substitute_template_cjk_and_missing() {
532        let mut vars = HashMap::new();
533        vars.insert("姓名".to_string(), "张三".to_string());
534        let (out, missing) = substitute_template("你好,{姓名}!{缺失}", &vars);
535        assert_eq!(out, "你好,张三!{缺失}");
536        assert_eq!(missing, vec!["缺失".to_string()]);
537    }
538
539    /// 0.22.0 audit fix (H-C1): `{{`/`}}` escape like lc-prompts.
540    #[test]
541    fn test_substitute_template_escaped_braces() {
542        let mut vars = HashMap::new();
543        vars.insert("x".to_string(), "V".to_string());
544        let (out, missing) = substitute_template("{{literal}} {x} }}end{{", &vars);
545        assert_eq!(out, "{literal} V }end{");
546        assert!(missing.is_empty());
547    }
548
549    #[test]
550    fn test_chain_error_display() {
551        let error = ChainError::MissingInput("test".to_string());
552        assert!(error.to_string().contains("Missing input"));
553
554        let error = ChainError::ExecutionError("test".to_string());
555        assert!(error.to_string().contains("Execution error"));
556    }
557
558    #[test]
559    fn test_chain_error_all_variants() {
560        let err = ChainError::MissingInput("key".to_string());
561        assert!(err.to_string().contains("key"));
562
563        let err = ChainError::OutputError("bad".to_string());
564        assert!(err.to_string().contains("bad"));
565
566        let err = ChainError::ExecutionError("fail".to_string());
567        assert!(err.to_string().contains("fail"));
568
569        let err = ChainError::StreamError("broken".to_string());
570        assert!(err.to_string().contains("broken"));
571
572        let err = ChainError::Other("misc".to_string());
573        assert!(err.to_string().contains("misc"));
574    }
575
576    /// P2-1: `Nested` preserves the original error chain — `source()` must
577    /// downcast back to the concrete `ChainError` variant instead of a
578    /// flattened string.
579    #[test]
580    fn test_chain_error_nested_preserves_source() {
581        let inner = ChainError::MissingInput("text".to_string());
582        let nested = ChainError::Nested {
583            context: "Step 0 (echo) execution failed".to_string(),
584            source: Box::new(inner),
585        };
586        assert!(nested
587            .to_string()
588            .contains("Step 0 (echo) execution failed"));
589        assert!(nested.to_string().contains("Missing input"));
590
591        let source = nested.source().expect("Nested must carry a source");
592        let downcast = source.downcast_ref::<ChainError>();
593        assert!(
594            matches!(downcast, Some(ChainError::MissingInput(k)) if k == "text"),
595            "source should downcast back to the original variant, got {downcast:?}"
596        );
597    }
598
599    #[test]
600    fn test_stream_token_debug() {
601        let token = StreamToken {
602            token: "hello".to_string(),
603            is_final: false,
604        };
605        assert!(format!("{:?}", token).contains("hello"));
606    }
607
608    /// P2-2: the default stream fails loudly when the chain produces a
609    /// non-string output instead of silently emitting a single empty token
610    /// (the old `unwrap_or("")`).
611    #[tokio::test]
612    async fn test_default_stream_errors_on_non_string_output() {
613        struct NonStringChain;
614        #[async_trait]
615        impl BaseChain for NonStringChain {
616            fn input_keys(&self) -> Vec<&str> {
617                vec![]
618            }
619            fn output_keys(&self) -> Vec<&str> {
620                vec!["count"]
621            }
622            async fn invoke(
623                &self,
624                _inputs: HashMap<String, Value>,
625            ) -> Result<ChainResult, ChainError> {
626                let mut result = HashMap::new();
627                result.insert("count".to_string(), json!(3));
628                Ok(result)
629            }
630        }
631
632        let chain = NonStringChain;
633        let err = match chain.stream(HashMap::new()).await {
634            Ok(_) => panic!("expected an OutputError"),
635            Err(e) => e,
636        };
637        assert!(
638            matches!(err, ChainError::OutputError(_)),
639            "expected OutputError, got {err:?}"
640        );
641    }
642
643    #[test]
644    fn test_validate_inputs_pass() {
645        struct PassthroughChain;
646        #[async_trait]
647        impl BaseChain for PassthroughChain {
648            fn input_keys(&self) -> Vec<&str> {
649                vec!["input"]
650            }
651            fn output_keys(&self) -> Vec<&str> {
652                vec!["output"]
653            }
654            async fn invoke(
655                &self,
656                inputs: HashMap<String, Value>,
657            ) -> Result<ChainResult, ChainError> {
658                Ok(inputs)
659            }
660        }
661
662        let chain = PassthroughChain;
663        let mut inputs = HashMap::new();
664        inputs.insert("input".to_string(), Value::String("test".to_string()));
665        assert!(chain.validate_inputs(&inputs).is_ok());
666    }
667
668    #[test]
669    fn test_validate_inputs_missing_key() {
670        struct PassthroughChain;
671        #[async_trait]
672        impl BaseChain for PassthroughChain {
673            fn input_keys(&self) -> Vec<&str> {
674                vec!["input"]
675            }
676            fn output_keys(&self) -> Vec<&str> {
677                vec!["output"]
678            }
679            async fn invoke(
680                &self,
681                _inputs: HashMap<String, Value>,
682            ) -> Result<ChainResult, ChainError> {
683                Ok(HashMap::new())
684            }
685        }
686
687        let chain = PassthroughChain;
688        let inputs = HashMap::new();
689        assert!(chain.validate_inputs(&inputs).is_err());
690    }
691
692    #[test]
693    fn test_default_chain_name() {
694        struct MyChain;
695        #[async_trait]
696        impl BaseChain for MyChain {
697            fn input_keys(&self) -> Vec<&str> {
698                vec![]
699            }
700            fn output_keys(&self) -> Vec<&str> {
701                vec![]
702            }
703            async fn invoke(
704                &self,
705                _inputs: HashMap<String, Value>,
706            ) -> Result<ChainResult, ChainError> {
707                Ok(HashMap::new())
708            }
709        }
710        let chain = MyChain;
711        assert_eq!(chain.name(), "chain");
712    }
713}