Skip to main content

lc_core/runnables/
branch.rs

1// lc-core/src/runnables/branch.rs
2//! RunnableBranch - conditional routing in LCEL pipelines.
3//!
4//! `RunnableBranch` evaluates conditions sequentially and executes
5//! the first matching branch. If no condition matches, the default
6//! branch is executed.
7
8use super::any::{into_runnable_any, RunnableAny};
9use super::config::RunnableConfig;
10use super::error::LcelError;
11use super::lambda::RunnableLambda;
12use super::runnable_trait::Runnable;
13use async_trait::async_trait;
14use futures_util::Stream;
15use std::any::Any;
16use std::pin::Pin;
17
18/// A `Runnable` that routes input to different branches based on conditions.
19///
20/// Conditions are evaluated in order; the first matching branch wins.
21/// If no condition matches, the default branch is used.
22///
23/// The input type `I` must be `Clone` because the input may need to be
24/// evaluated against multiple conditions before a match is found.
25///
26/// # Example
27///
28/// ```rust,ignore
29/// let branch = RunnableBranch::new(default_handler)
30///     .when_fn(|input: &String| input.len() > 100, long_handler)
31///     .when_fn(|input: &String| input.starts_with('?'), question_handler);
32/// ```
33pub struct RunnableBranch<I: Send + Sync + 'static, O: Send + Sync + 'static> {
34    branches: Vec<(Box<dyn RunnableAny>, Box<dyn RunnableAny>)>,
35    default: Box<dyn RunnableAny>,
36    _marker: std::marker::PhantomData<(I, O)>,
37}
38
39impl<I: Send + Sync + 'static, O: Send + Sync + 'static> std::fmt::Debug for RunnableBranch<I, O> {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("RunnableBranch")
42            .field("branches", &self.branches.len())
43            .field("input", &std::any::type_name::<I>())
44            .field("output", &std::any::type_name::<O>())
45            .finish()
46    }
47}
48
49impl<I: Clone + Send + Sync + 'static, O: Send + Sync + 'static> RunnableBranch<I, O> {
50    /// Create a new branch with a default runnable.
51    pub fn new<R>(default: R) -> Self
52    where
53        R: Runnable<I, O> + 'static,
54        R::Error: Into<LcelError>,
55    {
56        Self {
57            branches: Vec::new(),
58            default: into_runnable_any(default),
59            _marker: std::marker::PhantomData,
60        }
61    }
62
63    /// Add a branch with a condition runnable and a branch runnable.
64    ///
65    /// The condition runnable takes `I` and returns `bool`.
66    /// If the condition returns `true`, the branch runnable is executed.
67    pub fn when<R1, R2>(mut self, condition: R1, branch: R2) -> Self
68    where
69        R1: Runnable<I, bool> + 'static,
70        R1::Error: Into<LcelError>,
71        R2: Runnable<I, O> + 'static,
72        R2::Error: Into<LcelError>,
73    {
74        self.branches
75            .push((into_runnable_any(condition), into_runnable_any(branch)));
76        self
77    }
78
79    /// Add a branch with a synchronous condition closure.
80    ///
81    /// Convenience method that wraps the closure in a `RunnableLambda`.
82    pub fn when_fn<F, R2>(self, condition: F, branch: R2) -> Self
83    where
84        F: Fn(&I) -> bool + Send + Sync + 'static,
85        R2: Runnable<I, O> + 'static,
86        R2::Error: Into<LcelError>,
87    {
88        let condition_lambda = RunnableLambda::new_sync(move |input: I| condition(&input));
89        self.when(condition_lambda, branch)
90    }
91
92    /// Number of branches (excluding default).
93    pub fn len(&self) -> usize {
94        self.branches.len()
95    }
96
97    /// Whether there are no branches (only default).
98    pub fn is_empty(&self) -> bool {
99        self.branches.is_empty()
100    }
101}
102
103#[async_trait]
104impl<I: Clone + Send + Sync + 'static, O: Send + Sync + 'static> Runnable<I, O>
105    for RunnableBranch<I, O>
106{
107    type Error = LcelError;
108
109    /// Evaluate conditions in order, execute the first matching branch.
110    /// If no condition matches, execute the default.
111    async fn invoke(&self, input: I, config: Option<RunnableConfig>) -> Result<O, LcelError> {
112        for (condition, branch) in &self.branches {
113            // Clone input for condition evaluation (input is preserved for branch)
114            let cond_input: Box<dyn Any + Send> = Box::new(input.clone());
115            let cond_result = condition.invoke_any(cond_input, config.clone()).await?;
116            let matches: bool = cond_result.downcast::<bool>().map(|b| *b).map_err(|_| {
117                LcelError::TypeMismatch("branch condition must return bool".to_string())
118            })?;
119
120            if matches {
121                let branch_input: Box<dyn Any + Send> = Box::new(input);
122                let result = branch.invoke_any(branch_input, config).await?;
123                return result.downcast::<O>().map(|b| *b).map_err(|_| {
124                    LcelError::TypeMismatch(format!(
125                        "branch output downcast: expected {}",
126                        std::any::type_name::<O>()
127                    ))
128                });
129            }
130        }
131
132        // No condition matched, use default
133        let default_input: Box<dyn Any + Send> = Box::new(input);
134        let result = self.default.invoke_any(default_input, config).await?;
135        result.downcast::<O>().map(|b| *b).map_err(|_| {
136            LcelError::TypeMismatch(format!(
137                "branch default output downcast: expected {}",
138                std::any::type_name::<O>()
139            ))
140        })
141    }
142
143    /// Stream: invoke and return single-element stream.
144    async fn stream(
145        &self,
146        input: I,
147        config: Option<RunnableConfig>,
148    ) -> Result<Pin<Box<dyn Stream<Item = Result<O, LcelError>> + Send>>, LcelError> {
149        let result = self.invoke(input, config).await?;
150        Ok(Box::pin(futures_util::stream::once(
151            async move { Ok(result) },
152        )))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::RunnableConfig;
160    use async_trait::async_trait;
161    use futures_util::StreamExt;
162
163    struct EchoDefault;
164
165    #[async_trait]
166    impl Runnable<String, String> for EchoDefault {
167        type Error = std::convert::Infallible;
168
169        async fn invoke(
170            &self,
171            input: String,
172            _config: Option<RunnableConfig>,
173        ) -> Result<String, Self::Error> {
174            Ok(format!("default: {}", input))
175        }
176    }
177
178    struct LongHandler;
179
180    #[async_trait]
181    impl Runnable<String, String> for LongHandler {
182        type Error = std::convert::Infallible;
183
184        async fn invoke(
185            &self,
186            input: String,
187            _config: Option<RunnableConfig>,
188        ) -> Result<String, Self::Error> {
189            Ok(format!("long: {}", input))
190        }
191    }
192
193    #[tokio::test]
194    async fn branch_matches_condition() {
195        let branch =
196            RunnableBranch::new(EchoDefault).when_fn(|input: &String| input.len() > 5, LongHandler);
197
198        let result = branch
199            .invoke("hello world".to_string(), None)
200            .await
201            .unwrap();
202        assert_eq!(result, "long: hello world");
203    }
204
205    #[tokio::test]
206    async fn branch_falls_to_default() {
207        let branch = RunnableBranch::new(EchoDefault)
208            .when_fn(|input: &String| input.len() > 100, LongHandler);
209
210        let result = branch.invoke("hi".to_string(), None).await.unwrap();
211        assert_eq!(result, "default: hi");
212    }
213
214    #[tokio::test]
215    async fn branch_first_match_wins() {
216        let branch = RunnableBranch::new(EchoDefault)
217            .when_fn(
218                |input: &String| input.starts_with('h'),
219                RunnableLambda::new_sync(|s: String| format!("starts-h: {}", s)),
220            )
221            .when_fn(
222                |input: &String| input.len() > 3,
223                RunnableLambda::new_sync(|s: String| format!("long: {}", s)),
224            );
225
226        // "hello" starts with 'h', so first branch wins
227        let result = branch.invoke("hello".to_string(), None).await.unwrap();
228        assert_eq!(result, "starts-h: hello");
229    }
230
231    #[tokio::test]
232    async fn branch_stream_works() {
233        let branch =
234            RunnableBranch::new(EchoDefault).when_fn(|input: &String| input.len() > 5, LongHandler);
235
236        let mut stream = branch
237            .stream("hello world".to_string(), None)
238            .await
239            .unwrap();
240        let result = stream.next().await.unwrap().unwrap();
241        assert_eq!(result, "long: hello world");
242    }
243}