Skip to main content

klieo_flows/
loop_flow.rs

1//! Iterative flow. Repeats `body` until predicate returns `Done` or `max_iters` is hit.
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/// Predicate verdict for [`LoopFlow`].
11#[derive(Debug, Clone)]
12pub enum LoopVerdict {
13    /// Run another iteration.
14    Continue,
15    /// Terminate; return the most recent output.
16    Done,
17    /// Terminate with a [`FlowError::Loop`] containing this reason.
18    Failed(String),
19}
20
21const DEFAULT_MAX_ITERS: u32 = 16;
22
23/// Loop a single body flow until the predicate returns `Done`.
24pub struct LoopFlow {
25    name: String,
26    body: Arc<dyn Flow>,
27    predicate: Arc<dyn Fn(&Value) -> LoopVerdict + Send + Sync>,
28    max_iters: u32,
29}
30
31impl LoopFlow {
32    /// New loop. Default predicate returns `Done` immediately (single iter)
33    /// so the caller is forced to call [`LoopFlow::until`].
34    pub fn new(name: impl Into<String>, body: Arc<dyn Flow>) -> Self {
35        Self {
36            name: name.into(),
37            body,
38            predicate: Arc::new(|_| LoopVerdict::Done),
39            max_iters: DEFAULT_MAX_ITERS,
40        }
41    }
42
43    /// Replace the termination predicate.
44    pub fn until<F>(mut self, predicate: F) -> Self
45    where
46        F: Fn(&Value) -> LoopVerdict + Send + Sync + 'static,
47    {
48        self.predicate = Arc::new(predicate);
49        self
50    }
51
52    /// Override the default cap (16). The cap bounds the number of body
53    /// invocations — the body runs at most `max_iters` times.
54    pub fn with_max_iters(mut self, n: u32) -> Self {
55        self.max_iters = n;
56        self
57    }
58
59    /// Convenience constructor that wraps a concrete
60    /// [`klieo_core::agent::Agent`] in [`crate::flow::AgentFlow`] and
61    /// constructs a [`LoopFlow`] with the same defaults as [`LoopFlow::new`]
62    /// (single-iteration predicate, max 16 iters).
63    pub fn with_body_agent<A>(name: impl Into<String>, agent: A) -> Self
64    where
65        A: klieo_core::agent::Agent + 'static,
66    {
67        Self::new(
68            name,
69            std::sync::Arc::new(crate::flow::AgentFlow::new(agent))
70                as std::sync::Arc<dyn crate::flow::Flow>,
71        )
72    }
73}
74
75#[async_trait]
76impl Flow for LoopFlow {
77    fn name(&self) -> &str {
78        &self.name
79    }
80
81    async fn run(&self, ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
82        let span = tracing::info_span!(
83            "loop_flow.run",
84            flow = %self.name,
85            max_iters = self.max_iters
86        );
87        let _guard = span.enter();
88
89        let mut current = input;
90        for iter in 0..self.max_iters {
91            crate::flow::bail_if_cancelled(&ctx)?;
92            current = self.body.run(ctx.clone(), current).await?;
93            match (self.predicate)(&current) {
94                LoopVerdict::Continue => continue,
95                LoopVerdict::Done => return Ok(current),
96                LoopVerdict::Failed(reason) => {
97                    return Err(FlowError::Loop {
98                        reason,
99                        iter: iter + 1,
100                    });
101                }
102            }
103        }
104        Err(FlowError::Loop {
105            reason: "max_iters exceeded".into(),
106            iter: self.max_iters,
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::test_helpers::ctx;
115
116    struct IncrementFlow;
117
118    #[async_trait]
119    impl Flow for IncrementFlow {
120        fn name(&self) -> &str {
121            "inc"
122        }
123        async fn run(&self, _ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
124            let n = input.get("n").and_then(|v| v.as_u64()).unwrap_or(0);
125            Ok(serde_json::json!({ "n": n + 1 }))
126        }
127    }
128
129    #[tokio::test]
130    async fn predicate_done_immediately_runs_once() {
131        let f = LoopFlow::new("once", Arc::new(IncrementFlow));
132        let out = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap();
133        assert_eq!(out, serde_json::json!({"n": 1}));
134    }
135
136    struct CountingFlow(std::sync::Arc<std::sync::atomic::AtomicU32>);
137
138    #[async_trait]
139    impl Flow for CountingFlow {
140        fn name(&self) -> &str {
141            "counting"
142        }
143        async fn run(&self, _ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
144            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
145            Ok(input)
146        }
147    }
148
149    #[tokio::test]
150    async fn cancelled_ctx_returns_cancelled_without_running_body() {
151        use crate::test_helpers::cancelled_ctx;
152        let calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
153        let f = LoopFlow::new("c", Arc::new(CountingFlow(calls.clone())))
154            .until(|_| LoopVerdict::Continue);
155        let err = f
156            .run(cancelled_ctx(), serde_json::json!({"n": 0}))
157            .await
158            .unwrap_err();
159        assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
160        assert_eq!(
161            calls.load(std::sync::atomic::Ordering::SeqCst),
162            0,
163            "body must not run under a cancelled context"
164        );
165    }
166
167    #[tokio::test]
168    async fn continues_then_done_after_three() {
169        let f = LoopFlow::new("3x", Arc::new(IncrementFlow)).until(|v| {
170            let n = v.get("n").and_then(|v| v.as_u64()).unwrap_or(0);
171            if n >= 4 {
172                LoopVerdict::Done
173            } else {
174                LoopVerdict::Continue
175            }
176        });
177        let out = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap();
178        assert_eq!(out, serde_json::json!({"n": 4}));
179    }
180
181    #[tokio::test]
182    async fn predicate_failed_returns_loop_error() {
183        let f = LoopFlow::new("fail", Arc::new(IncrementFlow))
184            .until(|_| LoopVerdict::Failed("nope".into()));
185        let err = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
186        match err {
187            FlowError::Loop { reason, iter } => {
188                assert_eq!(reason, "nope");
189                assert_eq!(iter, 1);
190            }
191            other => panic!("expected Loop, got {other:?}"),
192        }
193    }
194
195    #[tokio::test]
196    async fn max_iters_exceeded() {
197        let f = LoopFlow::new("inf", Arc::new(IncrementFlow))
198            .until(|_| LoopVerdict::Continue)
199            .with_max_iters(2);
200        let err = f.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
201        match err {
202            FlowError::Loop { reason, iter } => {
203                assert_eq!(reason, "max_iters exceeded");
204                assert_eq!(iter, 2);
205            }
206            other => panic!("expected Loop, got {other:?}"),
207        }
208    }
209
210    #[tokio::test]
211    async fn with_body_agent_runs_agent_body_once_by_default() {
212        use async_trait::async_trait;
213        use klieo_core::agent::{Agent, AgentContext as Ctx};
214        use klieo_core::error::Error as CoreError;
215        use klieo_core::llm::ToolDef;
216        use serde::{Deserialize, Serialize};
217
218        #[derive(Deserialize, Serialize)]
219        struct In {
220            n: u64,
221        }
222        #[derive(Deserialize, Serialize, PartialEq, Debug)]
223        struct Out {
224            n: u64,
225        }
226
227        struct IncrementAgent;
228
229        #[async_trait]
230        impl Agent for IncrementAgent {
231            type Input = In;
232            type Output = Out;
233            type Error = CoreError;
234            fn name(&self) -> &str {
235                "inc"
236            }
237            fn system_prompt(&self) -> &str {
238                ""
239            }
240            fn tools(&self) -> &[ToolDef] {
241                &[]
242            }
243            async fn run(&self, _ctx: Ctx, input: In) -> Result<Out, CoreError> {
244                Ok(Out { n: input.n + 1 })
245            }
246        }
247
248        // Default predicate is Done-immediately → body runs exactly once.
249        let f = LoopFlow::with_body_agent("inc-loop", IncrementAgent);
250        let out = f.run(ctx(), serde_json::json!({"n": 5})).await.unwrap();
251        assert_eq!(out["n"], 6, "agent body must execute once");
252    }
253
254    #[tokio::test]
255    async fn with_body_agent_error_propagates_from_loop_run() {
256        use async_trait::async_trait;
257        use klieo_core::agent::{Agent, AgentContext as Ctx};
258        use klieo_core::error::Error as CoreError;
259        use klieo_core::llm::ToolDef;
260        use serde_json::Value;
261
262        struct AlwaysFailAgent;
263
264        #[async_trait]
265        impl Agent for AlwaysFailAgent {
266            type Input = Value;
267            type Output = Value;
268            type Error = CoreError;
269            fn name(&self) -> &str {
270                "always_fail"
271            }
272            fn system_prompt(&self) -> &str {
273                ""
274            }
275            fn tools(&self) -> &[ToolDef] {
276                &[]
277            }
278            async fn run(&self, _ctx: Ctx, _input: Value) -> Result<Value, CoreError> {
279                Err(CoreError::Other {
280                    message: "injected agent failure".into(),
281                    source: None,
282                })
283            }
284        }
285
286        let f = LoopFlow::with_body_agent("fail-loop", AlwaysFailAgent);
287        let result = f.run(ctx(), serde_json::json!({})).await;
288        assert!(
289            result.is_err(),
290            "with_body_agent error must propagate to LoopFlow::run"
291        );
292        assert!(
293            matches!(result.unwrap_err(), FlowError::Agent(_)),
294            "error must surface as FlowError::Agent"
295        );
296    }
297
298    #[tokio::test]
299    async fn output_threads_to_next_iteration() {
300        let f = LoopFlow::new("thread", Arc::new(IncrementFlow)).until(|v| {
301            let n = v.get("n").and_then(|v| v.as_u64()).unwrap_or(0);
302            if n >= 5 {
303                LoopVerdict::Done
304            } else {
305                LoopVerdict::Continue
306            }
307        });
308        let out = f.run(ctx(), serde_json::json!({"n": 2})).await.unwrap();
309        assert_eq!(out, serde_json::json!({"n": 5}));
310    }
311}