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        let output_key = self.output_keys().first().map(|k| (*k).to_string());
330        stream_chain_with_callbacks(
331            self.name(),
332            inputs,
333            config.clone(),
334            output_key,
335            |inputs| async move { self.stream_steps(inputs, config).await },
336        )
337        .await
338    }
339
340    fn name(&self) -> &str {
341        &self.name
342    }
343}
344
345impl std::fmt::Debug for SequentialChain {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.debug_struct("SequentialChain")
348            .field("steps", &self.chains.len())
349            .field("name", &self.name)
350            .finish()
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use async_trait::async_trait;
358    use futures_util::StreamExt;
359    use serde_json::json;
360
361    /// A simple mock chain that echoes input to output.
362    struct EchoChain {
363        input_key: String,
364        output_key: String,
365    }
366
367    impl EchoChain {
368        fn new(input_key: &str, output_key: &str) -> Self {
369            Self {
370                input_key: input_key.to_string(),
371                output_key: output_key.to_string(),
372            }
373        }
374    }
375
376    #[async_trait]
377    impl BaseChain for EchoChain {
378        fn input_keys(&self) -> Vec<&str> {
379            vec![&self.input_key]
380        }
381        fn output_keys(&self) -> Vec<&str> {
382            vec![&self.output_key]
383        }
384        async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
385            let mut result = HashMap::new();
386            if let Some(v) = inputs.get(&self.input_key) {
387                result.insert(self.output_key.clone(), v.clone());
388            }
389            Ok(result)
390        }
391    }
392
393    /// A mock chain that always fails with a specific `ChainError` variant.
394    struct FailingChain;
395
396    #[async_trait]
397    impl BaseChain for FailingChain {
398        fn input_keys(&self) -> Vec<&str> {
399            vec!["text"]
400        }
401        fn output_keys(&self) -> Vec<&str> {
402            vec![]
403        }
404        async fn invoke(&self, _inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
405            Err(ChainError::MissingInput("nested_key".to_string()))
406        }
407    }
408
409    /// A mock chain that transforms input (uppercases).
410    struct UppercaseChain {
411        input_key: String,
412        output_key: String,
413    }
414
415    #[async_trait]
416    impl BaseChain for UppercaseChain {
417        fn input_keys(&self) -> Vec<&str> {
418            vec![&self.input_key]
419        }
420        fn output_keys(&self) -> Vec<&str> {
421            vec![&self.output_key]
422        }
423        async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
424            let mut result = HashMap::new();
425            if let Some(Value::String(s)) = inputs.get(&self.input_key) {
426                result.insert(self.output_key.clone(), Value::String(s.to_uppercase()));
427            }
428            Ok(result)
429        }
430    }
431
432    #[tokio::test]
433    async fn test_sequential_chain_single_step() {
434        let chain = SequentialChain::new().add_chain(
435            Arc::new(EchoChain::new("text", "result")),
436            vec!["text"],
437            vec!["result"],
438        );
439
440        let mut inputs = HashMap::new();
441        inputs.insert("text".to_string(), json!("hello"));
442
443        let result = chain.invoke(inputs).await.unwrap();
444        assert_eq!(result.get("result").unwrap(), &json!("hello"));
445    }
446
447    #[tokio::test]
448    async fn test_sequential_chain_two_steps() {
449        let chain = SequentialChain::new()
450            .add_chain(
451                Arc::new(EchoChain::new("text", "intermediate")),
452                vec!["text"],
453                vec!["intermediate"],
454            )
455            .add_chain(
456                Arc::new(UppercaseChain {
457                    input_key: "intermediate".to_string(),
458                    output_key: "result".to_string(),
459                }),
460                vec!["intermediate"],
461                vec!["result"],
462            );
463
464        let mut inputs = HashMap::new();
465        inputs.insert("text".to_string(), json!("hello"));
466
467        let result = chain.invoke(inputs).await.unwrap();
468        assert_eq!(result.get("result").unwrap(), &json!("HELLO"));
469    }
470
471    #[tokio::test]
472    async fn test_sequential_chain_missing_input() {
473        let chain = SequentialChain::new().add_chain(
474            Arc::new(EchoChain::new("text", "result")),
475            vec!["text"],
476            vec!["result"],
477        );
478
479        let inputs = HashMap::new();
480        let result = chain.invoke(inputs).await;
481        assert!(result.is_err());
482    }
483
484    /// P2-1: sub-chain failures are wrapped in `Nested` and the original
485    /// `ChainError` variant stays inspectable via `source()` (not flattened
486    /// into a string).
487    #[tokio::test]
488    async fn test_sequential_chain_preserves_subchain_error() {
489        let chain = SequentialChain::new().add_chain(Arc::new(FailingChain), vec!["text"], vec![]);
490        let mut inputs = HashMap::new();
491        inputs.insert("text".to_string(), json!("hello"));
492        let err = chain.invoke(inputs).await.unwrap_err();
493        match &err {
494            ChainError::Nested { context, source } => {
495                assert!(context.contains("Step 0"), "context: {context}");
496                let downcast = source.downcast_ref::<ChainError>();
497                assert!(
498                    matches!(downcast, Some(ChainError::MissingInput(k)) if k == "nested_key"),
499                    "source should downcast to the original variant, got {downcast:?}"
500                );
501            }
502            other => panic!("expected ChainError::Nested, got {other:?}"),
503        }
504    }
505
506    /// P2-3: the streamed output corresponds to the last step's mapped global
507    /// output keys. Intermediate outputs feed `current_state` via their
508    /// `output_mapping`, and the last step's `output_mapping` names the global
509    /// output the token stream stands for.
510    #[tokio::test]
511    async fn test_sequential_chain_stream_applies_output_mapping() {
512        let chain = SequentialChain::new()
513            .add_chain_with_mapping(
514                Arc::new(EchoChain::new("text", "intermediate")),
515                HashMap::from([("text".to_string(), "text".to_string())]),
516                HashMap::from([("intermediate".to_string(), "intermediate".to_string())]),
517            )
518            .add_chain_with_mapping(
519                Arc::new(UppercaseChain {
520                    input_key: "intermediate".to_string(),
521                    output_key: "answer".to_string(),
522                }),
523                HashMap::from([("intermediate".to_string(), "intermediate".to_string())]),
524                HashMap::from([("answer".to_string(), "final_result".to_string())]),
525            );
526
527        // The last step's output_mapping names the global output keys.
528        assert_eq!(chain.output_keys(), vec!["final_result"]);
529
530        let mut inputs = HashMap::new();
531        inputs.insert("text".to_string(), json!("hello"));
532
533        let mut stream = chain.stream(inputs).await.unwrap();
534        let mut tokens = Vec::new();
535        while let Some(item) = stream.next().await {
536            tokens.push(item.unwrap());
537        }
538        let text: String = tokens.iter().map(|t| t.token.as_str()).collect();
539        assert_eq!(text, "HELLO");
540        assert!(tokens.last().unwrap().is_final);
541    }
542
543    /// P2-3: a last-step `output_mapping` referencing an output key the chain
544    /// cannot produce is caught on the stream path. Previously the mapping was
545    /// silently ignored — the stream emitted anyway and `output_keys()` (which
546    /// reports the mapped global keys) could advertise an output that would
547    /// never materialize.
548    #[tokio::test]
549    async fn test_sequential_chain_stream_validates_output_mapping() {
550        let chain = SequentialChain::new().add_chain_with_mapping(
551            Arc::new(EchoChain::new("text", "result")),
552            HashMap::from([("text".to_string(), "text".to_string())]),
553            HashMap::from([("missing".to_string(), "final_result".to_string())]),
554        );
555
556        let mut inputs = HashMap::new();
557        inputs.insert("text".to_string(), json!("hello"));
558
559        let err = match chain.stream(inputs).await {
560            Ok(_) => panic!("expected an OutputError"),
561            Err(e) => e,
562        };
563        assert!(
564            matches!(err, ChainError::OutputError(_)),
565            "expected OutputError, got {err:?}"
566        );
567    }
568
569    #[tokio::test]
570    async fn test_sequential_chain_with_name() {
571        let chain = SequentialChain::new().with_name("my_chain");
572        assert_eq!(chain.name(), "my_chain");
573    }
574
575    #[tokio::test]
576    async fn test_sequential_chain_default() {
577        let chain = SequentialChain::default();
578        assert_eq!(chain.name(), "sequential_chain");
579    }
580
581    #[tokio::test]
582    async fn test_sequential_chain_debug() {
583        let chain = SequentialChain::new().with_name("test_chain");
584        let debug_str = format!("{:?}", chain);
585        assert!(debug_str.contains("test_chain"));
586        assert!(debug_str.contains("0")); // 0 steps
587    }
588
589    #[tokio::test]
590    async fn test_sequential_chain_input_keys() {
591        let chain = SequentialChain::new().add_chain(
592            Arc::new(EchoChain::new("query", "result")),
593            vec!["query"],
594            vec!["result"],
595        );
596        assert_eq!(chain.input_keys(), vec!["query"]);
597    }
598
599    #[tokio::test]
600    async fn test_sequential_chain_output_keys() {
601        let chain = SequentialChain::new().add_chain(
602            Arc::new(EchoChain::new("query", "answer")),
603            vec!["query"],
604            vec!["answer"],
605        );
606        assert_eq!(chain.output_keys(), vec!["answer"]);
607    }
608}