use crate::error::FlowError;
use klieo_core::agent::AgentContext;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
type StageFn<I, O> = dyn Fn(AgentContext, I) -> Pin<Box<dyn Future<Output = Result<O, FlowError>> + Send>>
+ Send
+ Sync;
pub struct TypedSequentialFlow<I, O> {
name: String,
stage: Arc<StageFn<I, O>>,
}
impl TypedSequentialFlow<(), ()> {
pub fn start_with<A>(
name: impl Into<String>,
agent: A,
) -> TypedSequentialFlow<A::Input, A::Output>
where
A: klieo_core::agent::Agent + Send + Sync + 'static,
A::Input: Send + 'static,
A::Output: Send + 'static,
A::Error: std::error::Error + Send + Sync + 'static,
{
let agent = Arc::new(agent);
TypedSequentialFlow::new(name, move |ctx, input| {
let agent = Arc::clone(&agent);
async move {
agent
.run(ctx, input)
.await
.map_err(|e| FlowError::Agent(e.to_string()))
}
})
}
}
impl<I, O> TypedSequentialFlow<I, O>
where
I: Send + 'static,
O: Send + 'static,
{
pub fn new<F, Fut>(name: impl Into<String>, stage: F) -> Self
where
F: Fn(AgentContext, I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O, FlowError>> + Send + 'static,
{
let boxed: Arc<StageFn<I, O>> = Arc::new(move |ctx, input| Box::pin(stage(ctx, input)));
Self {
name: name.into(),
stage: boxed,
}
}
pub fn then_agent<A>(self, agent: A) -> TypedSequentialFlow<I, A::Output>
where
A: klieo_core::agent::Agent<Input = O> + Send + Sync + 'static,
A::Output: Send + 'static,
A::Error: std::error::Error + Send + Sync + 'static,
{
let agent = Arc::new(agent);
self.then(move |ctx, input| {
let agent = Arc::clone(&agent);
async move {
agent
.run(ctx, input)
.await
.map_err(|e| FlowError::Agent(e.to_string()))
}
})
}
pub fn then<F, Fut, P>(self, stage: F) -> TypedSequentialFlow<I, P>
where
F: Fn(AgentContext, O) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<P, FlowError>> + Send + 'static,
P: Send + 'static,
{
let prev = self.stage;
let next = Arc::new(stage);
let combined: Arc<StageFn<I, P>> = Arc::new(move |ctx: AgentContext, input: I| {
let prev = Arc::clone(&prev);
let next = Arc::clone(&next);
Box::pin(async move {
let intermediate = prev(ctx.clone(), input).await?;
next(ctx, intermediate).await
})
});
TypedSequentialFlow {
name: self.name,
stage: combined,
}
}
pub async fn run(&self, ctx: AgentContext, input: I) -> Result<O, FlowError> {
let span = tracing::info_span!("typed_sequential_flow.run", flow = %self.name);
let _guard = span.enter();
crate::flow::bail_if_cancelled(&ctx)?;
(self.stage)(ctx, input).await
}
pub fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::ctx;
#[derive(Debug, PartialEq)]
struct Ticket {
subject: String,
}
#[derive(Debug, PartialEq)]
struct Classified {
ticket: Ticket,
category: String,
}
#[derive(Debug, PartialEq)]
struct Reply(String);
#[tokio::test]
async fn single_stage_pipeline_runs() {
let flow: TypedSequentialFlow<Ticket, Classified> =
TypedSequentialFlow::new("classify", |_ctx, t: Ticket| async move {
Ok(Classified {
ticket: t,
category: "billing".into(),
})
});
let out = flow
.run(
ctx(),
Ticket {
subject: "refund".into(),
},
)
.await
.unwrap();
assert_eq!(out.category, "billing");
assert_eq!(out.ticket.subject, "refund");
}
#[tokio::test]
async fn two_stages_compose_typed_values() {
let flow = TypedSequentialFlow::new("triage", |_ctx, t: Ticket| async move {
Ok::<_, FlowError>(Classified {
ticket: t,
category: "billing".into(),
})
})
.then(|_ctx, c: Classified| async move {
Ok::<_, FlowError>(Reply(format!("re[{}]: {}", c.category, c.ticket.subject)))
});
let reply = flow
.run(
ctx(),
Ticket {
subject: "refund".into(),
},
)
.await
.unwrap();
assert_eq!(reply, Reply("re[billing]: refund".into()));
}
#[tokio::test]
async fn first_stage_error_short_circuits() {
let flow = TypedSequentialFlow::new("triage", |_ctx, _t: Ticket| async {
Err::<Classified, _>(FlowError::Agent("first failed".into()))
})
.then(|_ctx, _c: Classified| async { Ok::<_, FlowError>(Reply("never reached".into())) });
let err = flow
.run(
ctx(),
Ticket {
subject: "x".into(),
},
)
.await
.unwrap_err();
match err {
FlowError::Agent(s) => assert_eq!(s, "first failed"),
other => panic!("expected Agent, got {other:?}"),
}
}
#[tokio::test]
async fn second_stage_error_propagates() {
let flow = TypedSequentialFlow::new("triage", |_ctx, t: Ticket| async move {
Ok::<_, FlowError>(Classified {
ticket: t,
category: "billing".into(),
})
})
.then(|_ctx, _c: Classified| async {
Err::<Reply, _>(FlowError::Agent("second failed".into()))
});
let err = flow
.run(
ctx(),
Ticket {
subject: "x".into(),
},
)
.await
.unwrap_err();
match err {
FlowError::Agent(s) => assert_eq!(s, "second failed"),
other => panic!("expected Agent, got {other:?}"),
}
}
#[tokio::test]
async fn three_stages_thread_in_order() {
let flow = TypedSequentialFlow::new("counter", |_ctx, n: u32| async move {
Ok::<_, FlowError>(n + 1)
})
.then(|_ctx, n: u32| async move { Ok::<_, FlowError>(n * 2) })
.then(|_ctx, n: u32| async move { Ok::<_, FlowError>(n.to_string()) });
let out = flow.run(ctx(), 0_u32).await.unwrap();
assert_eq!(out, "2");
}
#[tokio::test]
async fn name_returns_configured_name() {
let flow: TypedSequentialFlow<u32, u32> =
TypedSequentialFlow::new("mine", |_ctx, n: u32| async move { Ok(n) });
assert_eq!(flow.name(), "mine");
}
#[tokio::test]
async fn cancelled_ctx_returns_cancelled_before_stage() {
use crate::test_helpers::cancelled_ctx;
let flow = TypedSequentialFlow::new("c", |_ctx, _n: u32| async move {
panic!("stage must not run under a cancelled context");
#[allow(unreachable_code)]
Ok::<u32, FlowError>(0)
});
let err = flow.run(cancelled_ctx(), 0_u32).await.unwrap_err();
assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
}
struct Double;
#[async_trait::async_trait]
impl klieo_core::agent::Agent for Double {
type Input = i32;
type Output = i32;
type Error = klieo_core::Error;
fn name(&self) -> &str {
"double"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[klieo_core::llm::ToolDef] {
&[]
}
async fn run(
&self,
_ctx: klieo_core::agent::AgentContext,
n: i32,
) -> Result<i32, klieo_core::Error> {
Ok(n * 2)
}
}
struct AddTen;
#[async_trait::async_trait]
impl klieo_core::agent::Agent for AddTen {
type Input = i32;
type Output = i32;
type Error = klieo_core::Error;
fn name(&self) -> &str {
"add-ten"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[klieo_core::llm::ToolDef] {
&[]
}
async fn run(
&self,
_ctx: klieo_core::agent::AgentContext,
n: i32,
) -> Result<i32, klieo_core::Error> {
Ok(n + 10)
}
}
#[tokio::test]
async fn start_with_and_then_agent_compose_correctly() {
let flow = TypedSequentialFlow::start_with("test", Double).then_agent(AddTen);
let result = flow.run(ctx(), 3).await.unwrap();
assert_eq!(result, 16, "3 * 2 = 6, + 10 = 16");
}
#[tokio::test]
async fn start_with_sets_name() {
let flow: TypedSequentialFlow<i32, i32> =
TypedSequentialFlow::start_with("my-pipeline", Double);
assert_eq!(flow.name(), "my-pipeline");
}
}