Multi-agent systems
As LLM workflows grow, a single agent that has accumulated 30+ tools, a bloated system prompt, and multiple responsibilities starts to degrade: it calls the wrong tools, hallucinates, and exhausts its context window. The fix is to split the work across several agents that each specialize in one area, coordinated either by a “manager” agent, by your own Rust code, or by peer-to-peer messaging.
Do you need one?
Section titled “Do you need one?”If your workflow lives in one domain with a focused set of tools (under 10-15), better prompting and context engineering will almost always beat the complexity of managing multiple agents. Try these first:
- Structured outputs to reduce ambiguity
- Better retrieval (improved chunking, more relevant datasets)
- Tighter constraints and clearer role definitions
Multi-agent systems pay off when you have:
- Agents needing 20+ tools that start calling the wrong ones
- Cross-domain coordination (e.g. documentation writing + product building)
- Context-window exhaustion you can’t solve with retrieval alone
- Clear role-delegation boundaries
If you can’t clearly articulate why you need multiple agents, keep it simple with one. To measure and improve a system you do build, see Observability.
Manager-worker pattern
Section titled “Manager-worker pattern”In the manager-worker pattern a manager agent delegates subtasks to worker agents and aggregates their results. In Rig a worker becomes a tool with Agent::into_tool(), and the manager registers it with .dynamic_tool(..).
graph TD A[User Request] --> B[Manager Agent] B --> C{Task Planning} C --> D[Decompose into Subtasks] D --> E[Worker 1] D --> F[Worker 2] D --> G[Worker 3] E --> K[Manager Agent<br/>Aggregation] F --> K G --> K K --> M[Final Response] style B fill:#D97E4A,color:#191F18 style E fill:#242424,color:#F6F3EC style F fill:#242424,color:#F6F3EC style G fill:#242424,color:#F6F3EC style K fill:#D97E4A,color:#191F18Here Alice manages Bob. Bob is turned into a tool, so Alice’s model can call him:
use rig::prelude::*;use rig::providers::openai::{self, OpenAI};
/// Manager-worker pattern with two agents, Alice (manager) and Bob (worker).async fn manager_worker_agent() -> anyhow::Result<()> { // Erase the model once and clone the handle for each agent. let model = OpenAI::from_env()?.completion(openai::GPT_5_5).erase();
let bob = AgentBuilder::new(model.clone()) .name("bob") .description("An employee who works in admin at FooBar Inc.") .preamble( "You are Bob, an employee in admin at FooBar Inc. Alice, your manager, \ may ask you to do things. You need to do them.", ) .build();
let alice = AgentBuilder::new(model) .name("alice") .preamble("You are a manager in the admin department at FooBar Inc. You manage Bob.") .dynamic_tool(bob.into_tool()?) .default_max_turns(3) .build();
let res = alice .prompt("Ask Bob to write an email for you and let me know what he has written.") .await?;
println!("Response: {}", res.output()); Ok(())}The tool takes a single prompt string argument. When Alice’s model calls it, Rig runs Bob with that prompt (inheriting the caller’s tool context), returns Bob’s text output as the tool result, and Alice continues her turn. A failed Bob run becomes a tool error that Alice’s model sees, rather than aborting Alice’s run.
Custom worker tools
Section titled “Custom worker tools”When the manager should pass structured arguments, or you want control over how the worker is prompted, wrap the worker in your own Tool:
struct Translator(Agent);
#[derive(Deserialize)]struct TranslatorArgs { text: String,}
impl Tool for Translator { const NAME: &'static str = "translator"; type Args = TranslatorArgs; type Output = String; type Error = PromptError;
fn description(&self) -> String { "Translate any text to English.".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "text": { "type": "string", "description": "Text to translate" } }, "required": ["text"] }) }
async fn call(&self, _ctx: &mut ToolContext, args: Self::Args) -> Result<String, PromptError> { Ok(self.0.prompt(args.text).await?.output()) }}Register it on the manager with .tool(Translator(translator_agent)).
Orchestrating agents in Rust
Section titled “Orchestrating agents in Rust”A manager agent lets the model decide who does what. When you already know the steps, orchestrate the agents in ordinary Rust instead: it is cheaper, faster, and easier to test. Sequential steps are just .awaits, and independent steps can run concurrently with tokio::join! or futures::future::join_all. See Workflows for the general patterns.
use rig::prelude::*;use rig::providers::openai::{self, OpenAI};
async fn research_and_write(topic: &str) -> anyhow::Result<String> { let model = OpenAI::from_env()?.completion(openai::GPT_5_5).erase();
let historian = AgentBuilder::new(model.clone()) .preamble("You summarize the history of a topic in five bullet points.") .build(); let economist = AgentBuilder::new(model.clone()) .preamble("You summarize the economics of a topic in five bullet points.") .build(); let writer = AgentBuilder::new(model) .preamble("You turn research notes into a short, readable article.") .build();
// Two independent workers, run concurrently. let (history, economics) = tokio::join!(historian.prompt(topic), economist.prompt(topic));
// One dependent step that combines their output. let notes = format!( "History:\n{}\n\nEconomics:\n{}", history?.output(), economics?.output() ); Ok(writer.prompt(notes).await?.output())}Swarm behaviour with the actor pattern
Section titled “Swarm behaviour with the actor pattern”Rig doesn’t ship a swarm primitive, but you can build one with the actor pattern: each agent runs its own loop, receives messages from peers over a channel, and can be nudged by an external trigger. Only the Rig-specific pieces are shown below; wiring up the channels and run loop is standard Tokio.
Define the messages exchanged between agents and the actor that owns a Rig agent:
use rig::prelude::*;use std::sync::Arc;use tokio::sync::{mpsc, RwLock};
/// Messages exchanged between agents.#[derive(Debug, Clone)]enum AgentMessage { Task(String), Response(String, String), // (from_agent_id, content) Trigger(String), Shutdown,}
/// An actor-based autonomous agent. Each holds its own Rig agent, an inbox,/// its history, and channels to its peers.struct AutonomousAgent { id: String, agent: Agent, history: Vec<String>, inbox: mpsc::Receiver<AgentMessage>, peers: Arc<RwLock<Vec<mpsc::Sender<AgentMessage>>>>,}Build each participant’s Agent once, with its own preamble, tools, RAG context or memory, and hand it to the actor. The run loop uses tokio::select! to react to either an inbound message or a periodic self-check timer, whichever fires first. On a Task, the agent prompts its model, records the result, and broadcasts it to peers; on Shutdown it breaks the loop:
use tokio::time::{interval, Duration};
impl AutonomousAgent { /// Broadcast a message to every registered peer. async fn broadcast(&self, message: AgentMessage) { for peer in self.peers.read().await.iter() { let _ = peer.send(message.clone()).await; } }
async fn run(mut self) { let mut tick = interval(Duration::from_secs(10)); loop { tokio::select! { Some(msg) = self.inbox.recv() => match msg { AgentMessage::Shutdown => break, AgentMessage::Task(task) => { if let Ok(response) = self.agent.prompt(task).await { let result = response.output(); self.history.push(result.clone()); self.broadcast(AgentMessage::Response(self.id.clone(), result)).await; } } AgentMessage::Trigger(msg) => { let _ = self.agent.prompt(msg).await; } AgentMessage::Response(from, content) => { self.history.push(format!("From {from}: {content}")); } }, // Autonomous periodic self-check (external trigger) _ = tick.tick() => { // e.g. summarize progress, enqueue follow-up work, etc. } } } }}To wire up a swarm, create one mpsc::channel per agent, register each agent’s sender with its peers, tokio::spawn every run() future, then seed the system with a Task and finish with Shutdown messages: all ordinary Tokio orchestration.
See also
Section titled “See also”- Agents: configure the specialized agents each pattern uses.
- Tools: the
Tooltrait and dynamic tools that agents-as-tools build on. - Workflows: sequential, parallel and routing patterns in plain Rust.
- Observability: measure and debug multi-agent systems.
- Runnable examples:
agent_with_agent_tool,multi_agent,agent_orchestrator.
