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.
The pipeline
Section titled “The pipeline”raw article ─▶ [extractor] ─▶ key points ─▶ [summarizer] ─▶ summary ─▶ [translator] ─▶ FrenchThree agents, each with a focused system prompt, sharing one provider.
Set up
Section titled “Set up”cargo new kova-pipeline && cd kova-pipelinecargo add kova-sdkcargo add tokio --features macros,rt-multi-threadThe program
Section titled “The program”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.
Variation: parallel perspectives
Section titled “Variation: parallel perspectives”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.)
Variation: route to the right specialist
Section titled “Variation: route to the right specialist”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.
Design notes
Section titled “Design notes”- 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 istokio::join!, routing is amatch. You control error handling, retries, branching, and where intermediate results are stored — nothing is hidden. - State stays with you. Each
run_stepis a fresh, stateless turn. If a stage needs multi-turn context, keep aVec<ConversationMessage>for it and feed it back (see Conversations & History).
- Give a specialist tools using A Research Agent.
- Make a router’s choice type-safe with Structured Output.