Skip to main content

lc_chains/
sequential_chain.rs

1// lc-chains/src/sequential_chain.rs
2//! Sequential Chain
3//!
4//! Execute multiple chains sequentially.
5
6use async_trait::async_trait;
7use lc_core::runnables::RunnableConfig;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use crate::base::{
13    run_chain_with_callbacks, stream_chain_with_callbacks, BaseChain, ChainError, ChainResult,
14    ChainStream,
15};
16
17/// Sequential Chain
18///
19/// Executes multiple chains sequentially, where the output of one chain
20/// can be used as the input of the next.
21pub struct SequentialChain {
22    /// Chain list.
23    chains: Vec<ChainStep>,
24
25    /// Chain name.
26    name: String,
27}
28
29/// Chain step.
30struct ChainStep {
31    /// Chain instance.
32    chain: Arc<dyn BaseChain>,
33
34    /// Input mapping (from global input or previous output).
35    input_mapping: HashMap<String, String>,
36
37    /// Output mapping (to global result).
38    output_mapping: HashMap<String, String>,
39}
40
41impl SequentialChain {
42    /// Create an empty SequentialChain.
43    pub fn new() -> Self {
44        Self {
45            chains: Vec::new(),
46            name: "sequential_chain".to_string(),
47        }
48    }
49
50    /// Set name.
51    pub fn with_name(mut self, name: impl Into<String>) -> Self {
52        self.name = name.into();
53        self
54    }
55
56    /// Add a Chain.
57    ///
58    /// # Arguments
59    /// * `chain` - Chain to add
60    /// * `input_keys` - Input keys (from global input)
61    /// * `output_keys` - Output keys (to global result)
62    pub fn add_chain(
63        mut self,
64        chain: Arc<dyn BaseChain>,
65        input_keys: Vec<&str>,
66        output_keys: Vec<&str>,
67    ) -> Self {
68        let input_mapping = input_keys
69            .into_iter()
70            .map(|k| (k.to_string(), k.to_string()))
71            .collect();
72
73        let output_mapping = output_keys
74            .into_iter()
75            .map(|k| (k.to_string(), k.to_string()))
76            .collect();
77
78        self.chains.push(ChainStep {
79            chain,
80            input_mapping,
81            output_mapping,
82        });
83
84        self
85    }
86
87    /// Add a Chain with mapping.
88    ///
89    /// # Arguments
90    /// * `chain` - Chain to add
91    /// * `input_mapping` - Input mapping {chain_input_key: global_key}
92    /// * `output_mapping` - Output mapping {chain_output_key: global_key}
93    pub fn add_chain_with_mapping(
94        mut self,
95        chain: Arc<dyn BaseChain>,
96        input_mapping: HashMap<String, String>,
97        output_mapping: HashMap<String, String>,
98    ) -> Self {
99        self.chains.push(ChainStep {
100            chain,
101            input_mapping,
102            output_mapping,
103        });
104
105        self
106    }
107
108    /// Run every step in order, threading `config` into each sub-chain via
109    /// `invoke_with_config` (never silently dropping it at the composition
110    /// boundary).
111    async fn run_steps(
112        &self,
113        inputs: HashMap<String, Value>,
114        config: Option<RunnableConfig>,
115    ) -> Result<ChainResult, ChainError> {
116        let mut current_state = inputs.clone();
117        let mut final_output = HashMap::new();
118
119        for (step_index, step) in self.chains.iter().enumerate() {
120            let mut chain_inputs = HashMap::new();
121            for (chain_key, global_key) in &step.input_mapping {
122                if let Some(value) = current_state.get(global_key) {
123                    chain_inputs.insert(chain_key.clone(), value.clone());
124                } else {
125                    return Err(ChainError::MissingInput(format!(
126                        "Step {}: missing input '{}' (mapped from '{}')",
127                        step_index, chain_key, global_key
128                    )));
129                }
130            }
131
132            let chain_output = step
133                .chain
134                .invoke_with_config(chain_inputs, config.clone())
135                .await
136                .map_err(|e| ChainError::Nested {
137                    context: format!(
138                        "Step {} ({}) execution failed",
139                        step_index,
140                        step.chain.name()
141                    ),
142                    source: Box::new(e),
143                })?;
144
145            for (chain_key, global_key) in &step.output_mapping {
146                if let Some(value) = chain_output.get(chain_key) {
147                    current_state.insert(global_key.clone(), value.clone());
148                    final_output.insert(global_key.clone(), value.clone());
149                } else {
150                    return Err(ChainError::OutputError(format!(
151                        "Step {} ({}) did not produce expected output key '{}' (mapped to '{}')",
152                        step_index,
153                        step.chain.name(),
154                        chain_key,
155                        global_key
156                    )));
157                }
158            }
159        }
160
161        Ok(final_output)
162    }
163
164    /// Stream body: run all chains except the last via `invoke_with_config`
165    /// (so callbacks flow through), stream the last chain via
166    /// `stream_with_config`.
167    async fn stream_steps(
168        &self,
169        inputs: HashMap<String, Value>,
170        config: Option<RunnableConfig>,
171    ) -> Result<ChainStream, ChainError> {
172        if self.chains.is_empty() {
173            return Err(ChainError::ExecutionError(
174                "SequentialChain has no chains".to_string(),
175            ));
176        }
177
178        let mut current_state = inputs.clone();
179
180        // Run all chains except the last via invoke
181        let last_idx = self.chains.len() - 1;
182        for (step_index, step) in self.chains[..last_idx].iter().enumerate() {
183            let mut chain_inputs = HashMap::new();
184            for (chain_key, global_key) in &step.input_mapping {
185                if let Some(value) = current_state.get(global_key) {
186                    chain_inputs.insert(chain_key.clone(), value.clone());
187                } else {
188                    return Err(ChainError::MissingInput(format!(
189                        "Step {}: missing input '{}' (mapped from '{}')",
190                        step_index, chain_key, global_key
191                    )));
192                }
193            }
194
195            let chain_output = step
196                .chain
197                .invoke_with_config(chain_inputs, config.clone())
198                .await
199                .map_err(|e| ChainError::Nested {
200                    context: format!(
201                        "Step {} ({}) execution failed",
202                        step_index,
203                        step.chain.name()
204                    ),
205                    source: Box::new(e),
206                })?;
207
208            for (chain_key, global_key) in &step.output_mapping {
209                if let Some(value) = chain_output.get(chain_key) {
210                    current_state.insert(global_key.clone(), value.clone());
211                } else {
212                    return Err(ChainError::OutputError(format!(
213                        "Step {} ({}) did not produce expected output key '{}'",
214                        step_index,
215                        step.chain.name(),
216                        chain_key,
217                    )));
218                }
219            }
220        }
221
222        // Stream the last chain
223        let last_step = &self.chains[last_idx];
224        let mut chain_inputs = HashMap::new();
225        for (chain_key, global_key) in &last_step.input_mapping {
226            if let Some(value) = current_state.get(global_key) {
227                chain_inputs.insert(chain_key.clone(), value.clone());
228            } else {
229                return Err(ChainError::MissingInput(format!(
230                    "Last step: missing input '{}' (mapped from '{}')",
231                    chain_key, global_key
232                )));
233            }
234        }
235
236        // P2-3: apply the last step's output_mapping to the streamed output.
237        // The token stream IS the last chain's output under its mapped global
238        // keys, so the mapping must be satisfiable — a reference to an output
239        // key the chain cannot produce is caught here. The invoke path already
240        // errors on this against the actual result dict; the stream path only
241        // sees an unkeyed token stream, so it validates against the chain's
242        // declared `output_keys()` instead. Without this, `output_keys()` (which
243        // reports the mapped global keys) could advertise an output the stream
244        // would never actually produce.
245        for (chain_key, global_key) in &last_step.output_mapping {
246            if !last_step.chain.output_keys().contains(&chain_key.as_str()) {
247                return Err(ChainError::OutputError(format!(
248                    "Last step ({}) did not produce expected output key '{}' (mapped to '{}')",
249                    last_step.chain.name(),
250                    chain_key,
251                    global_key
252                )));
253            }
254        }
255
256        last_step
257            .chain
258            .stream_with_config(chain_inputs, config.clone())
259            .await
260    }
261}
262
263impl Default for SequentialChain {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269#[async_trait]
270impl BaseChain for SequentialChain {
271    fn input_keys(&self) -> Vec<&str> {
272        if let Some(first) = self.chains.first() {
273            first.input_mapping.values().map(|s| s.as_str()).collect()
274        } else {
275            vec![]
276        }
277    }
278
279    fn output_keys(&self) -> Vec<&str> {
280        if let Some(last) = self.chains.last() {
281            last.output_mapping.values().map(|s| s.as_str()).collect()
282        } else {
283            vec![]
284        }
285    }
286
287    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
288        self.run_steps(inputs, None).await
289    }
290
291    /// Execute the Chain with config propagation.
292    ///
293    /// Dispatches this chain's `on_chain_start`/`on_chain_end` and threads
294    /// `config` (and thus callbacks) into every sub-chain via
295    /// `invoke_with_config` — previously sub-chains were called with plain
296    /// `invoke`, dropping the config at the composition boundary.
297    async fn invoke_with_config(
298        &self,
299        inputs: HashMap<String, Value>,
300        config: Option<RunnableConfig>,
301    ) -> Result<ChainResult, ChainError> {
302        run_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
303            self.run_steps(inputs, config).await
304        })
305        .await
306    }
307
308    /// Stream execution for SequentialChain.
309    ///
310    /// Runs all chains except the last via invoke (since their output feeds
311    /// into subsequent chains). The last chain's output is streamed token
312    /// by token by delegating to its `stream()` method; its `output_mapping`
313    /// names the global output keys that stream stands for (validated in
314    /// `Self::stream_steps` so a broken mapping fails loudly, P2-3).
315    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
316        self.stream_steps(inputs, None).await
317    }
318
319    /// Stream execute the Chain with config propagation.
320    ///
321    /// Dispatches this chain's `on_chain_start`/`on_chain_end` and threads
322    /// `config` into every sub-chain (invoke for intermediate steps,
323    /// stream for the last step).
324    async fn stream_with_config(
325        &self,
326        inputs: HashMap<String, Value>,
327        config: Option<RunnableConfig>,
328    ) -> Result<ChainStream, ChainError> {
329        stream_chain_with_callbacks(self.name(), inputs, config.clone(), |inputs| async move {
330            self.stream_steps(inputs, config).await
331        })
332        .await
333    }
334
335    fn name(&self) -> &str {
336        &self.name
337    }
338}
339
340impl std::fmt::Debug for SequentialChain {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("SequentialChain")
343            .field("steps", &self.chains.len())
344            .field("name", &self.name)
345            .finish()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use async_trait::async_trait;
353    use futures_util::StreamExt;
354    use serde_json::json;
355
356    /// A simple mock chain that echoes input to output.
357    struct EchoChain {
358        input_key: String,
359        output_key: String,
360    }
361
362    impl EchoChain {
363        fn new(input_key: &str, output_key: &str) -> Self {
364            Self {
365                input_key: input_key.to_string(),
366                output_key: output_key.to_string(),
367            }
368        }
369    }
370
371    #[async_trait]
372    impl BaseChain for EchoChain {
373        fn input_keys(&self) -> Vec<&str> {
374            vec![&self.input_key]
375        }
376        fn output_keys(&self) -> Vec<&str> {
377            vec![&self.output_key]
378        }
379        async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
380            let mut result = HashMap::new();
381            if let Some(v) = inputs.get(&self.input_key) {
382                result.insert(self.output_key.clone(), v.clone());
383            }
384            Ok(result)
385        }
386    }
387
388    /// A mock chain that always fails with a specific `ChainError` variant.
389    struct FailingChain;
390
391    #[async_trait]
392    impl BaseChain for FailingChain {
393        fn input_keys(&self) -> Vec<&str> {
394            vec!["text"]
395        }
396        fn output_keys(&self) -> Vec<&str> {
397            vec![]
398        }
399        async fn invoke(&self, _inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
400            Err(ChainError::MissingInput("nested_key".to_string()))
401        }
402    }
403
404    /// A mock chain that transforms input (uppercases).
405    struct UppercaseChain {
406        input_key: String,
407        output_key: String,
408    }
409
410    #[async_trait]
411    impl BaseChain for UppercaseChain {
412        fn input_keys(&self) -> Vec<&str> {
413            vec![&self.input_key]
414        }
415        fn output_keys(&self) -> Vec<&str> {
416            vec![&self.output_key]
417        }
418        async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
419            let mut result = HashMap::new();
420            if let Some(Value::String(s)) = inputs.get(&self.input_key) {
421                result.insert(self.output_key.clone(), Value::String(s.to_uppercase()));
422            }
423            Ok(result)
424        }
425    }
426
427    #[tokio::test]
428    async fn test_sequential_chain_single_step() {
429        let chain = SequentialChain::new().add_chain(
430            Arc::new(EchoChain::new("text", "result")),
431            vec!["text"],
432            vec!["result"],
433        );
434
435        let mut inputs = HashMap::new();
436        inputs.insert("text".to_string(), json!("hello"));
437
438        let result = chain.invoke(inputs).await.unwrap();
439        assert_eq!(result.get("result").unwrap(), &json!("hello"));
440    }
441
442    #[tokio::test]
443    async fn test_sequential_chain_two_steps() {
444        let chain = SequentialChain::new()
445            .add_chain(
446                Arc::new(EchoChain::new("text", "intermediate")),
447                vec!["text"],
448                vec!["intermediate"],
449            )
450            .add_chain(
451                Arc::new(UppercaseChain {
452                    input_key: "intermediate".to_string(),
453                    output_key: "result".to_string(),
454                }),
455                vec!["intermediate"],
456                vec!["result"],
457            );
458
459        let mut inputs = HashMap::new();
460        inputs.insert("text".to_string(), json!("hello"));
461
462        let result = chain.invoke(inputs).await.unwrap();
463        assert_eq!(result.get("result").unwrap(), &json!("HELLO"));
464    }
465
466    #[tokio::test]
467    async fn test_sequential_chain_missing_input() {
468        let chain = SequentialChain::new().add_chain(
469            Arc::new(EchoChain::new("text", "result")),
470            vec!["text"],
471            vec!["result"],
472        );
473
474        let inputs = HashMap::new();
475        let result = chain.invoke(inputs).await;
476        assert!(result.is_err());
477    }
478
479    /// P2-1: sub-chain failures are wrapped in `Nested` and the original
480    /// `ChainError` variant stays inspectable via `source()` (not flattened
481    /// into a string).
482    #[tokio::test]
483    async fn test_sequential_chain_preserves_subchain_error() {
484        let chain = SequentialChain::new().add_chain(Arc::new(FailingChain), vec!["text"], vec![]);
485        let mut inputs = HashMap::new();
486        inputs.insert("text".to_string(), json!("hello"));
487        let err = chain.invoke(inputs).await.unwrap_err();
488        match &err {
489            ChainError::Nested { context, source } => {
490                assert!(context.contains("Step 0"), "context: {context}");
491                let downcast = source.downcast_ref::<ChainError>();
492                assert!(
493                    matches!(downcast, Some(ChainError::MissingInput(k)) if k == "nested_key"),
494                    "source should downcast to the original variant, got {downcast:?}"
495                );
496            }
497            other => panic!("expected ChainError::Nested, got {other:?}"),
498        }
499    }
500
501    /// P2-3: the streamed output corresponds to the last step's mapped global
502    /// output keys. Intermediate outputs feed `current_state` via their
503    /// `output_mapping`, and the last step's `output_mapping` names the global
504    /// output the token stream stands for.
505    #[tokio::test]
506    async fn test_sequential_chain_stream_applies_output_mapping() {
507        let chain = SequentialChain::new()
508            .add_chain_with_mapping(
509                Arc::new(EchoChain::new("text", "intermediate")),
510                HashMap::from([("text".to_string(), "text".to_string())]),
511                HashMap::from([("intermediate".to_string(), "intermediate".to_string())]),
512            )
513            .add_chain_with_mapping(
514                Arc::new(UppercaseChain {
515                    input_key: "intermediate".to_string(),
516                    output_key: "answer".to_string(),
517                }),
518                HashMap::from([("intermediate".to_string(), "intermediate".to_string())]),
519                HashMap::from([("answer".to_string(), "final_result".to_string())]),
520            );
521
522        // The last step's output_mapping names the global output keys.
523        assert_eq!(chain.output_keys(), vec!["final_result"]);
524
525        let mut inputs = HashMap::new();
526        inputs.insert("text".to_string(), json!("hello"));
527
528        let mut stream = chain.stream(inputs).await.unwrap();
529        let mut tokens = Vec::new();
530        while let Some(item) = stream.next().await {
531            tokens.push(item.unwrap());
532        }
533        let text: String = tokens.iter().map(|t| t.token.as_str()).collect();
534        assert_eq!(text, "HELLO");
535        assert!(tokens.last().unwrap().is_final);
536    }
537
538    /// P2-3: a last-step `output_mapping` referencing an output key the chain
539    /// cannot produce is caught on the stream path. Previously the mapping was
540    /// silently ignored — the stream emitted anyway and `output_keys()` (which
541    /// reports the mapped global keys) could advertise an output that would
542    /// never materialize.
543    #[tokio::test]
544    async fn test_sequential_chain_stream_validates_output_mapping() {
545        let chain = SequentialChain::new().add_chain_with_mapping(
546            Arc::new(EchoChain::new("text", "result")),
547            HashMap::from([("text".to_string(), "text".to_string())]),
548            HashMap::from([("missing".to_string(), "final_result".to_string())]),
549        );
550
551        let mut inputs = HashMap::new();
552        inputs.insert("text".to_string(), json!("hello"));
553
554        let err = match chain.stream(inputs).await {
555            Ok(_) => panic!("expected an OutputError"),
556            Err(e) => e,
557        };
558        assert!(
559            matches!(err, ChainError::OutputError(_)),
560            "expected OutputError, got {err:?}"
561        );
562    }
563
564    #[tokio::test]
565    async fn test_sequential_chain_with_name() {
566        let chain = SequentialChain::new().with_name("my_chain");
567        assert_eq!(chain.name(), "my_chain");
568    }
569
570    #[tokio::test]
571    async fn test_sequential_chain_default() {
572        let chain = SequentialChain::default();
573        assert_eq!(chain.name(), "sequential_chain");
574    }
575
576    #[tokio::test]
577    async fn test_sequential_chain_debug() {
578        let chain = SequentialChain::new().with_name("test_chain");
579        let debug_str = format!("{:?}", chain);
580        assert!(debug_str.contains("test_chain"));
581        assert!(debug_str.contains("0")); // 0 steps
582    }
583
584    #[tokio::test]
585    async fn test_sequential_chain_input_keys() {
586        let chain = SequentialChain::new().add_chain(
587            Arc::new(EchoChain::new("query", "result")),
588            vec!["query"],
589            vec!["result"],
590        );
591        assert_eq!(chain.input_keys(), vec!["query"]);
592    }
593
594    #[tokio::test]
595    async fn test_sequential_chain_output_keys() {
596        let chain = SequentialChain::new().add_chain(
597            Arc::new(EchoChain::new("query", "answer")),
598            vec!["query"],
599            vec!["answer"],
600        );
601        assert_eq!(chain.output_keys(), vec!["answer"]);
602    }
603}