Skip to main content

klieo_flows/
parallel.rs

1//! Fan-out / fan-in flow. All branches see the same input concurrently.
2
3use crate::error::FlowError;
4use crate::flow::Flow;
5use async_trait::async_trait;
6use futures::future::try_join_all;
7use klieo_core::agent::AgentContext;
8use serde_json::{Map, Value};
9use std::sync::Arc;
10
11/// Fan-out the input to N branches, run them concurrently, return a JSON
12/// object keyed by branch name. First error fails the whole flow (other
13/// branches are dropped).
14pub struct ParallelFlow {
15    name: String,
16    branches: Vec<(String, Arc<dyn Flow>)>,
17}
18
19impl ParallelFlow {
20    /// New empty parallel flow.
21    pub fn new(name: impl Into<String>) -> Self {
22        Self {
23            name: name.into(),
24            branches: Vec::new(),
25        }
26    }
27
28    /// Append a branch. The `key` becomes the JSON key in the output object.
29    /// Duplicate keys overwrite — caller must ensure uniqueness.
30    pub fn branch(mut self, key: impl Into<String>, flow: Arc<dyn Flow>) -> Self {
31        self.branches.push((key.into(), flow));
32        self
33    }
34
35    /// Convenience builder that wraps a concrete [`klieo_core::agent::Agent`]
36    /// in [`crate::flow::AgentFlow`] + upcasts to `Arc<dyn Flow>` in one
37    /// call. The `key` becomes the JSON key in the output object.
38    pub fn branch_agent<A>(self, key: impl Into<String>, agent: A) -> Self
39    where
40        A: klieo_core::agent::Agent + 'static,
41    {
42        self.branch(
43            key,
44            std::sync::Arc::new(crate::flow::AgentFlow::new(agent))
45                as std::sync::Arc<dyn crate::flow::Flow>,
46        )
47    }
48}
49
50#[async_trait]
51impl Flow for ParallelFlow {
52    fn name(&self) -> &str {
53        &self.name
54    }
55
56    async fn run(&self, ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
57        let span = tracing::info_span!(
58            "parallel_flow.run",
59            flow = %self.name,
60            branches = self.branches.len()
61        );
62        let _guard = span.enter();
63
64        crate::flow::bail_if_cancelled(&ctx)?;
65        let futs = self.branches.iter().map(|(key, flow)| {
66            let ctx = ctx.clone();
67            let input = input.clone();
68            let key = key.clone();
69            let flow = flow.clone();
70            async move {
71                let out = flow.run(ctx, input).await?;
72                Ok::<(String, Value), FlowError>((key, out))
73            }
74        });
75
76        let results = try_join_all(futs).await?;
77        let mut obj = Map::with_capacity(results.len());
78        for (k, v) in results {
79            obj.insert(k, v);
80        }
81        Ok(Value::Object(obj))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::test_helpers::{cancelled_ctx, ctx};
89
90    struct Boom;
91    #[async_trait]
92    impl Flow for Boom {
93        fn name(&self) -> &str {
94            "boom"
95        }
96        async fn run(&self, _c: AgentContext, _i: Value) -> Result<Value, FlowError> {
97            panic!("branch must not run under a cancelled context")
98        }
99    }
100
101    #[tokio::test]
102    async fn cancelled_ctx_returns_cancelled_before_fan_out() {
103        let f = ParallelFlow::new("p").branch("b", Arc::new(Boom));
104        let err = f
105            .run(cancelled_ctx(), serde_json::json!({}))
106            .await
107            .unwrap_err();
108        assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
109    }
110
111    struct ConstFlow {
112        name: String,
113        value: Value,
114    }
115
116    #[async_trait]
117    impl Flow for ConstFlow {
118        fn name(&self) -> &str {
119            &self.name
120        }
121        async fn run(&self, _ctx: AgentContext, _input: Value) -> Result<Value, FlowError> {
122            Ok(self.value.clone())
123        }
124    }
125
126    struct FailFlow;
127
128    #[async_trait]
129    impl Flow for FailFlow {
130        fn name(&self) -> &str {
131            "fail"
132        }
133        async fn run(&self, _ctx: AgentContext, _input: Value) -> Result<Value, FlowError> {
134            Err(FlowError::Agent("boom".into()))
135        }
136    }
137
138    #[tokio::test]
139    async fn three_branches_collated_by_key() {
140        let f = ParallelFlow::new("fan")
141            .branch(
142                "a",
143                Arc::new(ConstFlow {
144                    name: "a".into(),
145                    value: serde_json::json!(1),
146                }),
147            )
148            .branch(
149                "b",
150                Arc::new(ConstFlow {
151                    name: "b".into(),
152                    value: serde_json::json!("two"),
153                }),
154            )
155            .branch(
156                "c",
157                Arc::new(ConstFlow {
158                    name: "c".into(),
159                    value: serde_json::json!(true),
160                }),
161            );
162        let out = f.run(ctx(), serde_json::json!(null)).await.unwrap();
163        assert_eq!(out, serde_json::json!({"a": 1, "b": "two", "c": true}));
164    }
165
166    #[tokio::test]
167    async fn one_branch_error_fails_whole_flow() {
168        let f = ParallelFlow::new("p")
169            .branch(
170                "ok",
171                Arc::new(ConstFlow {
172                    name: "ok".into(),
173                    value: serde_json::json!(1),
174                }),
175            )
176            .branch("bad", Arc::new(FailFlow));
177        let err = f.run(ctx(), serde_json::json!(null)).await.unwrap_err();
178        assert!(matches!(err, FlowError::Agent(s) if s == "boom"));
179    }
180
181    #[tokio::test]
182    async fn empty_branch_list_returns_empty_object() {
183        let f = ParallelFlow::new("empty");
184        let out = f.run(ctx(), serde_json::json!({"x": 1})).await.unwrap();
185        assert_eq!(out, serde_json::json!({}));
186    }
187
188    #[tokio::test]
189    async fn branch_agent_output_appears_under_key() {
190        use async_trait::async_trait;
191        use klieo_core::agent::{Agent, AgentContext as Ctx};
192        use klieo_core::error::Error as CoreError;
193        use klieo_core::llm::ToolDef;
194        use serde::{Deserialize, Serialize};
195
196        #[derive(Deserialize, Serialize)]
197        struct In {
198            msg: String,
199        }
200        #[derive(Deserialize, Serialize, PartialEq, Debug)]
201        struct Out {
202            echoed: String,
203        }
204
205        struct EchoAgent;
206
207        #[async_trait]
208        impl Agent for EchoAgent {
209            type Input = In;
210            type Output = Out;
211            type Error = CoreError;
212            fn name(&self) -> &str {
213                "echo"
214            }
215            fn system_prompt(&self) -> &str {
216                ""
217            }
218            fn tools(&self) -> &[ToolDef] {
219                &[]
220            }
221            async fn run(&self, _ctx: Ctx, input: In) -> Result<Out, CoreError> {
222                Ok(Out { echoed: input.msg })
223            }
224        }
225
226        let f = ParallelFlow::new("p").branch_agent("echo_key", EchoAgent);
227        let out = f
228            .run(ctx(), serde_json::json!({"msg": "hello"}))
229            .await
230            .unwrap();
231        assert_eq!(out["echo_key"]["echoed"], "hello");
232    }
233
234    #[tokio::test]
235    async fn branch_agent_error_propagates_from_parallel_run() {
236        use async_trait::async_trait;
237        use klieo_core::agent::{Agent, AgentContext as Ctx};
238        use klieo_core::error::Error as CoreError;
239        use klieo_core::llm::ToolDef;
240        use serde_json::Value;
241
242        struct AlwaysFailAgent;
243
244        #[async_trait]
245        impl Agent for AlwaysFailAgent {
246            type Input = Value;
247            type Output = Value;
248            type Error = CoreError;
249            fn name(&self) -> &str {
250                "always_fail"
251            }
252            fn system_prompt(&self) -> &str {
253                ""
254            }
255            fn tools(&self) -> &[ToolDef] {
256                &[]
257            }
258            async fn run(&self, _ctx: Ctx, _input: Value) -> Result<Value, CoreError> {
259                Err(CoreError::Other {
260                    message: "injected agent failure".into(),
261                    source: None,
262                })
263            }
264        }
265
266        let f = ParallelFlow::new("p").branch_agent("failing_key", AlwaysFailAgent);
267        let result = f.run(ctx(), serde_json::json!({})).await;
268        assert!(
269            result.is_err(),
270            "branch_agent error must propagate to ParallelFlow::run"
271        );
272        assert!(
273            matches!(result.unwrap_err(), FlowError::Agent(_)),
274            "error must surface as FlowError::Agent"
275        );
276    }
277
278    #[tokio::test]
279    async fn weird_keys_preserved_verbatim() {
280        let f = ParallelFlow::new("weird")
281            .branch(
282                "dot.key",
283                Arc::new(ConstFlow {
284                    name: "n1".into(),
285                    value: serde_json::json!(1),
286                }),
287            )
288            .branch(
289                "dash-key",
290                Arc::new(ConstFlow {
291                    name: "n2".into(),
292                    value: serde_json::json!(2),
293                }),
294            );
295        let out = f.run(ctx(), serde_json::json!(null)).await.unwrap();
296        assert_eq!(out, serde_json::json!({"dot.key": 1, "dash-key": 2}));
297    }
298}