1use 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
55pub struct TypedSequentialFlow<I, O> {
60 name: String,
61 stage: Arc<StageFn<I, O>>,
62}
63
64impl TypedSequentialFlow<(), ()> {
65 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 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 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 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 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 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 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}