Skip to main content

klieo_flows/
typed.rs

1//! Typed sequential flow builder.
2//!
3//! [`SequentialFlow`] round-trips through `serde_json::Value` at every
4//! step boundary so it can compose heterogeneous `Arc<dyn Flow>`
5//! references. For pipelines where every stage is in-process Rust
6//! code, the round-trip is unnecessary overhead and forces every
7//! stage type to implement `Serialize` + `Deserialize`.
8//!
9//! [`TypedSequentialFlow`] threads typed values between stages in
10//! memory. Each `.then` appends a stage whose input type is the
11//! previous stage's output type. The composed flow is itself a
12//! `TypedSequentialFlow<I, O>` where `I` is the first stage's input
13//! and `O` is the last stage's output.
14//!
15//! Use [`SequentialFlow`](crate::SequentialFlow) when the pipeline
16//! must accept heterogeneous step types via `Arc<dyn Flow>` (the
17//! JSON-marshalled type-erased path).
18//!
19//! # Example
20//!
21//! ```no_run
22//! use klieo_flows::TypedSequentialFlow;
23//! use klieo_flows::FlowError;
24//! use klieo_core::agent::AgentContext;
25//!
26//! struct Ticket { subject: String }
27//! struct Classified { ticket: Ticket, category: String }
28//! struct Reply(String);
29//!
30//! # async fn _example(ctx: AgentContext, t: Ticket) -> Result<Reply, FlowError> {
31//! let flow = TypedSequentialFlow::new("triage", |_ctx, t: Ticket| async {
32//!         Ok::<_, FlowError>(Classified { ticket: t, category: "billing".into() })
33//!     })
34//!     .then(|_ctx, c: Classified| async move {
35//!         Ok::<_, FlowError>(Reply(format!("re: {}", c.ticket.subject)))
36//!     });
37//!
38//! let reply: Reply = flow.run(ctx, t).await?;
39//! # Ok(reply)
40//! # }
41//! ```
42//!
43//! [`SequentialFlow`]: crate::SequentialFlow
44
45use crate::error::FlowError;
46use klieo_core::agent::AgentContext;
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::Arc;
50
51type StageFn<I, O> = dyn Fn(AgentContext, I) -> Pin<Box<dyn Future<Output = Result<O, FlowError>> + Send>>
52    + Send
53    + Sync;
54
55/// Pipeline of typed transformations.
56///
57/// Constructed with [`new`](Self::new); extended with
58/// [`then`](Self::then). Drive with [`run`](Self::run).
59pub struct TypedSequentialFlow<I, O> {
60    name: String,
61    stage: Arc<StageFn<I, O>>,
62}
63
64impl TypedSequentialFlow<(), ()> {
65    /// Start a typed pipeline with a named [`klieo_core::agent::Agent`] as the first stage.
66    ///
67    /// Equivalent to [`TypedSequentialFlow::new`] with an auto-wrapping
68    /// closure, but takes any concrete [`klieo_core::agent::Agent`] directly
69    /// — no manual `map_err` needed.
70    ///
71    /// # Example
72    /// ```ignore
73    /// use klieo_flows::TypedSequentialFlow;
74    ///
75    /// // `Classifier` implements `klieo_core::agent::Agent`.
76    /// let flow = TypedSequentialFlow::start_with("pipeline", Classifier)
77    ///     .then_agent(Retriever)
78    ///     .then_agent(Drafter);
79    /// ```
80    pub fn start_with<A>(
81        name: impl Into<String>,
82        agent: A,
83    ) -> TypedSequentialFlow<A::Input, A::Output>
84    where
85        A: klieo_core::agent::Agent + Send + Sync + 'static,
86        A::Input: Send + 'static,
87        A::Output: Send + 'static,
88        A::Error: std::error::Error + Send + Sync + 'static,
89    {
90        let agent = Arc::new(agent);
91        TypedSequentialFlow::new(name, move |ctx, input| {
92            let agent = Arc::clone(&agent);
93            async move {
94                agent
95                    .run(ctx, input)
96                    .await
97                    .map_err(|e| FlowError::Agent(e.to_string()))
98            }
99        })
100    }
101}
102
103impl<I, O> TypedSequentialFlow<I, O>
104where
105    I: Send + 'static,
106    O: Send + 'static,
107{
108    /// Start a typed pipeline with the given name and first stage.
109    pub fn new<F, Fut>(name: impl Into<String>, stage: F) -> Self
110    where
111        F: Fn(AgentContext, I) -> Fut + Send + Sync + 'static,
112        Fut: Future<Output = Result<O, FlowError>> + Send + 'static,
113    {
114        let boxed: Arc<StageFn<I, O>> = Arc::new(move |ctx, input| Box::pin(stage(ctx, input)));
115        Self {
116            name: name.into(),
117            stage: boxed,
118        }
119    }
120
121    /// Append a stage from a concrete [`klieo_core::agent::Agent`].
122    ///
123    /// The agent's [`Input`](klieo_core::agent::Agent::Input) must match
124    /// this flow's current output type `O`. Returns a new
125    /// `TypedSequentialFlow<I, A::Output>`.
126    pub fn then_agent<A>(self, agent: A) -> TypedSequentialFlow<I, A::Output>
127    where
128        A: klieo_core::agent::Agent<Input = O> + Send + Sync + 'static,
129        A::Output: Send + 'static,
130        A::Error: std::error::Error + Send + Sync + 'static,
131    {
132        let agent = Arc::new(agent);
133        self.then(move |ctx, input| {
134            let agent = Arc::clone(&agent);
135            async move {
136                agent
137                    .run(ctx, input)
138                    .await
139                    .map_err(|e| FlowError::Agent(e.to_string()))
140            }
141        })
142    }
143
144    /// Append a stage that consumes the previous output type `O` and
145    /// produces a new output type `P`.
146    pub fn then<F, Fut, P>(self, stage: F) -> TypedSequentialFlow<I, P>
147    where
148        F: Fn(AgentContext, O) -> Fut + Send + Sync + 'static,
149        Fut: Future<Output = Result<P, FlowError>> + Send + 'static,
150        P: Send + 'static,
151    {
152        let prev = self.stage;
153        let next = Arc::new(stage);
154        let combined: Arc<StageFn<I, P>> = Arc::new(move |ctx: AgentContext, input: I| {
155            let prev = Arc::clone(&prev);
156            let next = Arc::clone(&next);
157            Box::pin(async move {
158                let intermediate = prev(ctx.clone(), input).await?;
159                next(ctx, intermediate).await
160            })
161        });
162        TypedSequentialFlow {
163            name: self.name,
164            stage: combined,
165        }
166    }
167
168    /// Run the pipeline end-to-end. The composed stage chain is wrapped
169    /// in a single `typed_sequential_flow.run` tracing span.
170    pub async fn run(&self, ctx: AgentContext, input: I) -> Result<O, FlowError> {
171        let span = tracing::info_span!("typed_sequential_flow.run", flow = %self.name);
172        let _guard = span.enter();
173        crate::flow::bail_if_cancelled(&ctx)?;
174        (self.stage)(ctx, input).await
175    }
176
177    /// Configured pipeline name.
178    pub fn name(&self) -> &str {
179        &self.name
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::test_helpers::ctx;
187
188    #[derive(Debug, PartialEq)]
189    struct Ticket {
190        subject: String,
191    }
192
193    #[derive(Debug, PartialEq)]
194    struct Classified {
195        ticket: Ticket,
196        category: String,
197    }
198
199    #[derive(Debug, PartialEq)]
200    struct Reply(String);
201
202    #[tokio::test]
203    async fn single_stage_pipeline_runs() {
204        let flow: TypedSequentialFlow<Ticket, Classified> =
205            TypedSequentialFlow::new("classify", |_ctx, t: Ticket| async move {
206                Ok(Classified {
207                    ticket: t,
208                    category: "billing".into(),
209                })
210            });
211        let out = flow
212            .run(
213                ctx(),
214                Ticket {
215                    subject: "refund".into(),
216                },
217            )
218            .await
219            .unwrap();
220        assert_eq!(out.category, "billing");
221        assert_eq!(out.ticket.subject, "refund");
222    }
223
224    #[tokio::test]
225    async fn two_stages_compose_typed_values() {
226        let flow = TypedSequentialFlow::new("triage", |_ctx, t: Ticket| async move {
227            Ok::<_, FlowError>(Classified {
228                ticket: t,
229                category: "billing".into(),
230            })
231        })
232        .then(|_ctx, c: Classified| async move {
233            Ok::<_, FlowError>(Reply(format!("re[{}]: {}", c.category, c.ticket.subject)))
234        });
235        let reply = flow
236            .run(
237                ctx(),
238                Ticket {
239                    subject: "refund".into(),
240                },
241            )
242            .await
243            .unwrap();
244        assert_eq!(reply, Reply("re[billing]: refund".into()));
245    }
246
247    #[tokio::test]
248    async fn first_stage_error_short_circuits() {
249        let flow = TypedSequentialFlow::new("triage", |_ctx, _t: Ticket| async {
250            Err::<Classified, _>(FlowError::Agent("first failed".into()))
251        })
252        .then(|_ctx, _c: Classified| async { Ok::<_, FlowError>(Reply("never reached".into())) });
253        let err = flow
254            .run(
255                ctx(),
256                Ticket {
257                    subject: "x".into(),
258                },
259            )
260            .await
261            .unwrap_err();
262        match err {
263            FlowError::Agent(s) => assert_eq!(s, "first failed"),
264            other => panic!("expected Agent, got {other:?}"),
265        }
266    }
267
268    #[tokio::test]
269    async fn second_stage_error_propagates() {
270        let flow = TypedSequentialFlow::new("triage", |_ctx, t: Ticket| async move {
271            Ok::<_, FlowError>(Classified {
272                ticket: t,
273                category: "billing".into(),
274            })
275        })
276        .then(|_ctx, _c: Classified| async {
277            Err::<Reply, _>(FlowError::Agent("second failed".into()))
278        });
279        let err = flow
280            .run(
281                ctx(),
282                Ticket {
283                    subject: "x".into(),
284                },
285            )
286            .await
287            .unwrap_err();
288        match err {
289            FlowError::Agent(s) => assert_eq!(s, "second failed"),
290            other => panic!("expected Agent, got {other:?}"),
291        }
292    }
293
294    #[tokio::test]
295    async fn three_stages_thread_in_order() {
296        let flow = TypedSequentialFlow::new("counter", |_ctx, n: u32| async move {
297            Ok::<_, FlowError>(n + 1)
298        })
299        .then(|_ctx, n: u32| async move { Ok::<_, FlowError>(n * 2) })
300        .then(|_ctx, n: u32| async move { Ok::<_, FlowError>(n.to_string()) });
301        // ((0 + 1) * 2).to_string() == "2"
302        let out = flow.run(ctx(), 0_u32).await.unwrap();
303        assert_eq!(out, "2");
304    }
305
306    #[tokio::test]
307    async fn name_returns_configured_name() {
308        let flow: TypedSequentialFlow<u32, u32> =
309            TypedSequentialFlow::new("mine", |_ctx, n: u32| async move { Ok(n) });
310        assert_eq!(flow.name(), "mine");
311    }
312
313    #[tokio::test]
314    async fn cancelled_ctx_returns_cancelled_before_stage() {
315        use crate::test_helpers::cancelled_ctx;
316        let flow = TypedSequentialFlow::new("c", |_ctx, _n: u32| async move {
317            panic!("stage must not run under a cancelled context");
318            #[allow(unreachable_code)]
319            Ok::<u32, FlowError>(0)
320        });
321        let err = flow.run(cancelled_ctx(), 0_u32).await.unwrap_err();
322        assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
323    }
324
325    struct Double;
326
327    #[async_trait::async_trait]
328    impl klieo_core::agent::Agent for Double {
329        type Input = i32;
330        type Output = i32;
331        type Error = klieo_core::Error;
332
333        fn name(&self) -> &str {
334            "double"
335        }
336        fn system_prompt(&self) -> &str {
337            ""
338        }
339        fn tools(&self) -> &[klieo_core::llm::ToolDef] {
340            &[]
341        }
342        async fn run(
343            &self,
344            _ctx: klieo_core::agent::AgentContext,
345            n: i32,
346        ) -> Result<i32, klieo_core::Error> {
347            Ok(n * 2)
348        }
349    }
350
351    struct AddTen;
352
353    #[async_trait::async_trait]
354    impl klieo_core::agent::Agent for AddTen {
355        type Input = i32;
356        type Output = i32;
357        type Error = klieo_core::Error;
358
359        fn name(&self) -> &str {
360            "add-ten"
361        }
362        fn system_prompt(&self) -> &str {
363            ""
364        }
365        fn tools(&self) -> &[klieo_core::llm::ToolDef] {
366            &[]
367        }
368        async fn run(
369            &self,
370            _ctx: klieo_core::agent::AgentContext,
371            n: i32,
372        ) -> Result<i32, klieo_core::Error> {
373            Ok(n + 10)
374        }
375    }
376
377    #[tokio::test]
378    async fn start_with_and_then_agent_compose_correctly() {
379        let flow = TypedSequentialFlow::start_with("test", Double).then_agent(AddTen);
380        let result = flow.run(ctx(), 3).await.unwrap();
381        assert_eq!(result, 16, "3 * 2 = 6, + 10 = 16");
382    }
383
384    #[tokio::test]
385    async fn start_with_sets_name() {
386        let flow: TypedSequentialFlow<i32, i32> =
387            TypedSequentialFlow::start_with("my-pipeline", Double);
388        assert_eq!(flow.name(), "my-pipeline");
389    }
390}