1use 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#[derive(Debug, Clone)]
12pub enum LoopVerdict {
13 Continue,
15 Done,
17 Failed(String),
19}
20
21const DEFAULT_MAX_ITERS: u32 = 16;
22
23pub 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 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 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 pub fn with_max_iters(mut self, n: u32) -> Self {
55 self.max_iters = n;
56 self
57 }
58
59 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)(¤t) {
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 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}