Skip to content

A Multi-Agent Pipeline

Some problems are best split across specialists: an extractor, a summarizer, a translator. This example wires three focused agents into a pipeline where each one’s output feeds the next.

There’s no orchestrator type to learn — because Kova agents are stateless, composing them is just calling run and passing text along. Orchestration is a host concern, and plain Rust (?, tokio::join!, a match) expresses sequential, parallel, and router shapes directly.

raw article ─▶ [extractor] ─▶ key points ─▶ [summarizer] ─▶ summary ─▶ [translator] ─▶ French

Three agents, each with a focused system prompt, sharing one provider.

Terminal window
cargo new kova-pipeline && cd kova-pipeline
cargo add kova-sdk
cargo add tokio --features macros,rt-multi-thread
src/main.rs
use std::sync::Arc;
use kova_sdk::prelude::*;
use kova_sdk::provider::openai::{OpenAiCompatibleProvider, OpenAiProviderConfig};
fn user_message(text: &str) -> ConversationMessage {
ConversationMessage { role: Role::User, content: vec![ContentBlock::Text { text: text.into() }] }
}
// Build a single-purpose agent from a system prompt.
fn specialist(provider: Arc<dyn LlmProvider>, prompt: &str) -> Result<Agent, KovaError> {
AgentBuilder::new()
.provider(provider)
.system_prompt(prompt)
.build()
}
// Run one agent on one input and return its answer text. This is the whole
// "step" primitive — stateless in, text out.
async fn run_step(agent: &Agent, input: &str) -> Result<String, KovaError> {
let history = vec![user_message(input)];
Ok(agent.run(&history).await?.text)
}
#[tokio::main]
async fn main() -> Result<(), KovaError> {
let config = OpenAiProviderConfig::new("https://api.openai.com", "gpt-4o-mini")
.with_api_key(std::env::var("OPENAI_API_KEY").expect("set OPENAI_API_KEY"));
let provider: Arc<dyn LlmProvider> = Arc::new(OpenAiCompatibleProvider::new(config)?);
// --- Three specialists -------------------------------------------------
let extractor = specialist(
provider.clone(),
"Extract the 3-5 most important factual points from the input as a \
terse bulleted list. No commentary.",
)?;
let summarizer = specialist(
provider.clone(),
"Rewrite the input bullet points into a single tight paragraph a \
busy executive could read in 15 seconds.",
)?;
let translator = specialist(
provider.clone(),
"Translate the input into fluent French. Output ONLY the translation.",
)?;
let article = "Rust is a systems programming language focused on safety, \
speed, and concurrency. It guarantees memory safety without a garbage \
collector via its ownership and borrowing model. Since its 1.0 release \
in 2015 it has been voted the most-loved language in the Stack Overflow \
survey for several consecutive years, and is increasingly used in \
operating systems, browsers, and cloud infrastructure.";
// --- Sequential pipeline: feed each output into the next ---------------
let points = run_step(&extractor, article).await?;
let summary = run_step(&summarizer, &points).await?;
let french = run_step(&translator, &summary).await?;
println!("=== French executive summary ===\n{french}");
Ok(())
}

Each stage receives the previous stage’s output as its input: the extractor’s bullets feed the summarizer, whose paragraph feeds the translator. The whole pipeline is three ?-chained calls — no framework, just data flow.

Want three independent takes on the same input instead of a chain? Run the agents concurrently with tokio::join! — each Agent is Send + Sync, so this is safe and needs no extra machinery:

let (points, summary, french) = tokio::join!(
run_step(&extractor, article),
run_step(&summarizer, article),
run_step(&translator, article),
);
for (name, result) in [("extractor", points), ("summarizer", summary), ("translator", french)] {
match result {
Ok(text) => println!("--- {name} ---\n{text}\n"),
Err(e) => eprintln!("!!! {name} failed: {e}"),
}
}

All three run on article at once, and because you hold each Result independently, one agent failing doesn’t sink the others. (For a dynamic number of agents, collect the futures into a Vec and use futures::future::join_all.)

Add a router agent that reads the request and names the downstream agent, then dispatch with a match:

let router = specialist(
provider.clone(),
"You are a router. Reply with EXACTLY one word naming the agent to use: \
`summarizer` or `translator`. No other text.",
)?;
let request = "Please translate this memo into French: …";
let choice = run_step(&router, request).await?;
let downstream = match choice.trim() {
"translator" => &translator,
_ => &summarizer,
};
let output = run_step(downstream, request).await?;
println!("{output}");

For a router that must return one of a fixed set of labels reliably, reach for structured output — an enum schema guarantees the router emits a valid choice instead of stray prose.

  • Each specialist is a full Agent — it can have its own tools, system prompt, and even its own provider. A pipeline can mix a cheap local model for extraction with a strong hosted model for the final write-up.
  • Composition is just Rust. Sequential is ?-chaining, parallel is tokio::join!, routing is a match. You control error handling, retries, branching, and where intermediate results are stored — nothing is hidden.
  • State stays with you. Each run_step is a fresh, stateless turn. If a stage needs multi-turn context, keep a Vec<ConversationMessage> for it and feed it back (see Conversations & History).