Skip to main content

klieo_flows/
sequential.rs

1//! Ordered pipeline of flows; output of step N feeds input of step N+1.
2
3use crate::error::FlowError;
4use crate::flow::Flow;
5use async_trait::async_trait;
6use klieo_core::agent::AgentContext;
7use serde_json::Value;
8use std::sync::Arc;
9
10/// Ordered pipeline. Each step's output is threaded as the next step's
11/// input. Empty pipelines return their input unchanged (identity).
12/// First error short-circuits.
13pub struct SequentialFlow {
14    name: String,
15    steps: Vec<Arc<dyn Flow>>,
16}
17
18impl SequentialFlow {
19    /// New empty pipeline with the given name.
20    pub fn new(name: impl Into<String>) -> Self {
21        Self {
22            name: name.into(),
23            steps: Vec::new(),
24        }
25    }
26
27    /// Append a step. Builder-style: returns self.
28    pub fn step(mut self, flow: Arc<dyn Flow>) -> Self {
29        self.steps.push(flow);
30        self
31    }
32
33    /// Convenience builder that wraps a concrete [`klieo_core::agent::Agent`]
34    /// in [`crate::flow::AgentFlow`] + upcasts to `Arc<dyn Flow>` in one
35    /// call.
36    ///
37    /// Equivalent to:
38    /// ```ignore
39    /// .step(std::sync::Arc::new(AgentFlow::new(agent)) as std::sync::Arc<dyn Flow>)
40    /// ```
41    pub fn step_agent<A>(self, agent: A) -> Self
42    where
43        A: klieo_core::agent::Agent + 'static,
44    {
45        self.step(std::sync::Arc::new(crate::flow::AgentFlow::new(agent))
46            as std::sync::Arc<dyn crate::flow::Flow>)
47    }
48}
49
50#[async_trait]
51impl Flow for SequentialFlow {
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!("sequential_flow.run", flow = %self.name);
58        let _guard = span.enter();
59        let mut current = input;
60        for (i, step) in self.steps.iter().enumerate() {
61            crate::flow::bail_if_cancelled(&ctx)?;
62            tracing::debug!(step_index = i, step_name = step.name(), "sequential step");
63            current = step.run(ctx.clone(), current).await?;
64        }
65        Ok(current)
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::test_helpers::{cancelled_ctx, ctx};
73
74    struct Boom;
75    #[async_trait]
76    impl Flow for Boom {
77        fn name(&self) -> &str {
78            "boom"
79        }
80        async fn run(&self, _c: AgentContext, _i: Value) -> Result<Value, FlowError> {
81            panic!("step must not run under a cancelled context")
82        }
83    }
84
85    #[tokio::test]
86    async fn cancelled_ctx_returns_cancelled_before_any_step() {
87        let f = SequentialFlow::new("s").step(Arc::new(Boom));
88        let err = f
89            .run(cancelled_ctx(), serde_json::json!({}))
90            .await
91            .unwrap_err();
92        assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
93    }
94
95    struct IncrementFlow {
96        name: String,
97    }
98
99    #[async_trait]
100    impl Flow for IncrementFlow {
101        fn name(&self) -> &str {
102            &self.name
103        }
104        async fn run(&self, _ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
105            let n = input.get("n").and_then(|v| v.as_u64()).unwrap_or(0);
106            Ok(serde_json::json!({ "n": n + 1 }))
107        }
108    }
109
110    struct FailFlow;
111
112    #[async_trait]
113    impl Flow for FailFlow {
114        fn name(&self) -> &str {
115            "fail"
116        }
117        async fn run(&self, _ctx: AgentContext, _input: Value) -> Result<Value, FlowError> {
118            Err(FlowError::Agent("boom".into()))
119        }
120    }
121
122    #[tokio::test]
123    async fn empty_pipeline_returns_input_unchanged() {
124        let f = SequentialFlow::new("empty");
125        let out = f.run(ctx(), serde_json::json!({"n": 7})).await.unwrap();
126        assert_eq!(out, serde_json::json!({"n": 7}));
127    }
128
129    #[tokio::test]
130    async fn three_steps_increment_in_order() {
131        let f = SequentialFlow::new("incs")
132            .step(Arc::new(IncrementFlow { name: "a".into() }))
133            .step(Arc::new(IncrementFlow { name: "b".into() }))
134            .step(Arc::new(IncrementFlow { name: "c".into() }));
135        let out = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap();
136        assert_eq!(out, serde_json::json!({"n": 3}));
137    }
138
139    #[tokio::test]
140    async fn step_2_error_short_circuits() {
141        let f = SequentialFlow::new("with_fail")
142            .step(Arc::new(IncrementFlow { name: "a".into() }))
143            .step(Arc::new(FailFlow))
144            .step(Arc::new(IncrementFlow { name: "c".into() }));
145        let err = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
146        match err {
147            FlowError::Agent(s) => assert_eq!(s, "boom"),
148            other => panic!("expected Agent, got {other:?}"),
149        }
150    }
151
152    #[tokio::test]
153    async fn name_returns_configured_name() {
154        let f = SequentialFlow::new("my_pipe");
155        assert_eq!(f.name(), "my_pipe");
156    }
157}
158
159#[cfg(test)]
160mod step_agent_tests {
161    use super::*;
162    use crate::test_helpers::ctx;
163    use async_trait::async_trait;
164    use klieo_core::agent::{Agent, AgentContext};
165    use klieo_core::error::Error as CoreError;
166    use klieo_core::llm::ToolDef;
167    use serde::{Deserialize, Serialize};
168
169    #[derive(Deserialize, Serialize, PartialEq, Debug)]
170    struct EchoIn {
171        msg: String,
172    }
173    #[derive(Deserialize, Serialize, PartialEq, Debug)]
174    struct EchoOut {
175        echoed: String,
176    }
177
178    struct EchoAgent;
179
180    #[async_trait]
181    impl Agent for EchoAgent {
182        type Input = EchoIn;
183        type Output = EchoOut;
184        type Error = CoreError;
185        fn name(&self) -> &str {
186            "echo"
187        }
188        fn system_prompt(&self) -> &str {
189            ""
190        }
191        fn tools(&self) -> &[ToolDef] {
192            &[]
193        }
194        async fn run(&self, _ctx: AgentContext, input: EchoIn) -> Result<EchoOut, CoreError> {
195            Ok(EchoOut { echoed: input.msg })
196        }
197    }
198
199    #[tokio::test]
200    async fn step_agent_chains_typed_agent_without_upcast_boilerplate() {
201        let pipeline = SequentialFlow::new("step-agent-smoke").step_agent(EchoAgent);
202        let out = pipeline
203            .run(ctx(), serde_json::json!({"msg": "hi"}))
204            .await
205            .expect("run");
206        assert_eq!(out, serde_json::json!({"echoed": "hi"}));
207    }
208}