multi-agenttutorialairustv0.1.8

Building Multi-Agent Systems with ADK-Rust

Learn when and how to use different multi-agent patterns—from simple coordination to complex graph-based orchestration—with Rust's type safety and performance.

·15 min read·ADK-Rust v0.1.8

1. Introduction

The Problem: AI That Hits a Wall

You've built your first AI agent. It's impressive—it can answer questions, summarize documents, maybe even write code. But then reality hits:

"Can you check my last invoice and also help me set up the API?"

Your agent struggles. It wasn't trained on billing systems AND developer docs AND troubleshooting workflows. Its instruction prompt is already 2000 tokens trying to cover everything. Response quality degrades.

This is the single-agent ceiling. As your application grows, you face painful tradeoffs:

  • Bloated prompts: Every new capability means longer instructions, higher latency, and more confusion for the model
  • Jack of all trades: One agent handling billing, support, AND sales becomes mediocre at all three
  • Impossible maintenance: Changing billing logic shouldn't risk breaking your support flows
  • No specialization: Your math agent can't have a calculator tool while your research agent has web search—they share everything

The Solution: Specialized Agents Working Together

Multi-agent systems solve this by decomposing complex tasks into specialized roles. Instead of one overwhelmed generalist, you create focused specialists:

  • Customer service: A coordinator routes users to billing, technical support, or sales specialists—each with focused training and tools
  • Content creation: A research agent gathers facts, a writer crafts the narrative, an editor polishes—each agent masters one skill
  • Code generation: A planner designs the architecture, a coder implements, a reviewer catches bugs—different perspectives improve quality

The result? Each agent stays focused, prompts stay manageable, and you can update billing logic without touching support. It's microservices for AI.

What You'll Learn

ADK-Rust provides three progressively powerful patterns for multi-agent orchestration. This tutorial will teach you:

  • When to use each pattern based on your requirements
  • How to implement them with production-ready Rust code
  • Why the architectural tradeoffs matter for your specific use case

2. Choosing the Right Pattern

Before diving into code, let's understand what each pattern offers:

PatternBest ForControl LevelComplexity
CoordinatorConversation handoffsLLM decidesLow
AgentToolResponse processingCoordinator processesMedium
Supervisor GraphComplex workflowsFull state managementHigh

3. Pattern 1: The Coordinator (Sub-Agents)

🎯 Use Case: Customer Service Routing

You're building a customer service bot. Users might ask about billing, request technical help, or inquire about new features. Each domain requires specialized knowledge, but users shouldn't need to know which department to contact.

The Coordinator pattern uses automatic agent transfer. When you add sub-agents via .sub_agent(), ADK-Rust injects a transfer_to_agent tool. The LLM decides when to hand off based on the conversation.

UserCoordinatorRoutes requeststo specialistsBillingSub-agentSupportSub-agentSalesSub-agenttransfer_to_agenttransfer_to_agenttransfer_to_agent

Key Characteristics

  • Seamless handoff: User continues naturally with the specialist
  • LLM-driven routing: The coordinator decides based on conversation context
  • Conversation continuity: Session history is maintained across transfers
  • No response processing: Specialist talks directly to user after transfer

Implementation

use adk_rust::prelude::*;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let api_key = std::env::var("GOOGLE_API_KEY")?;
    let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.0-flash")?);

    // Specialist: Billing Agent
    // Clear description helps the coordinator know when to transfer
    let billing_agent = LlmAgentBuilder::new("billing_agent")
        .description(
            "Handles all billing questions: invoices, payments, refunds,              subscription plans, and account charges. Transfer here for              any money-related questions."
        )
        .instruction(
            "You are a billing specialist. Answer questions about invoices,              payments, and subscription plans. Be concise and accurate.              If asked about technical issues, suggest transferring to support."
        )
        .model(model.clone())
        .build()?;

    // Specialist: Technical Support Agent
    let support_agent = LlmAgentBuilder::new("support_agent")
        .description(
            "Provides technical support: troubleshooting, bug reports,              feature questions, and integration help. Transfer here for              any technical problems."
        )
        .instruction(
            "You are a technical support specialist. Help users troubleshoot              issues step by step. Ask clarifying questions when needed.              Be patient and thorough."
        )
        .model(model.clone())
        .build()?;

    // Coordinator: Routes to specialists
    let coordinator = LlmAgentBuilder::new("coordinator")
        .description("Customer service coordinator")
        .instruction(
            "You are a friendly customer service coordinator. Your job is to:\n             1. Greet users warmly\n             2. Understand their needs\n             3. Route to the right specialist:\n                - Billing questions → transfer to billing_agent\n                - Technical issues → transfer to support_agent\n             4. Handle general questions yourself\n\n             Always explain who you're connecting them with."
        )
        .model(model.clone())
        .sub_agent(Arc::new(billing_agent))
        .sub_agent(Arc::new(support_agent))
        .build()?;

    // Run with the built-in launcher
    Launcher::new(Arc::new(coordinator))
        .run()
        .await?;

    Ok(())
}

Example Conversation

User: Hi, I have a question about my bill

[coordinator]: Hello! I'd be happy to help with your billing question. Let me connect you with our billing specialist who can assist you.

System: 🔄 Transfer to: billing_agent

[billing_agent]: Hi there! I'm the billing specialist. I can help with invoices, payments, and subscription questions. What would you like to know about your bill?

User: Why was I charged twice this month?

[billing_agent]: I'll look into that duplicate charge for you...

✅ When to Use Coordinator

  • User should interact directly with specialists
  • Routing decisions are straightforward
  • You don't need to process specialist responses
  • Conversation flow is linear (one specialist at a time)

4. Pattern 2: Agents as Tools (AgentTool)

🎯 Use Case: Knowledge Aggregation

You're building a smart assistant that answers questions spanning multiple domains. A user asks "What is 15% of 250, and why is that number significant in history?" You need to call a math expert, then a trivia expert, and combine their answers.

The AgentTool pattern wraps agents as callable tools. Unlike sub-agents, the coordinator invokes specialists programmatically and receives their responses to process or combine before replying to the user.

UserCoordinatorCalls agents as toolsProcesses responsesAggregates resultsReturns to userMath ExpertAgentTool+ calculatorTrivia ExpertAgentToolLLM knowledgeResearcherAgentTool+ web_searchcall →← response

Key Differences from Coordinator

Coordinator (Sub-Agents)

  • • Specialist talks to user directly
  • • One specialist at a time
  • • No response processing

AgentTool

  • • Coordinator receives responses
  • • Can call multiple specialists
  • • Aggregates and summarizes

Implementation

use adk_agent::LlmAgentBuilder;
use adk_tool::{AgentTool, FunctionTool};
use adk_core::ToolContext;
use serde_json::{json, Value};
use std::sync::Arc;

// Calculator tool for the math agent
async fn calculator(
    _ctx: Arc<dyn ToolContext>,
    args: Value
) -> Result<Value, adk_core::AdkError> {
    let operation = args["operation"].as_str().unwrap_or("add");
    let a = args["a"].as_f64().unwrap_or(0.0);
    let b = args["b"].as_f64().unwrap_or(0.0);

    let result = match operation {
        "add" => a + b,
        "multiply" => a * b,
        "percent" => a * (b / 100.0),
        _ => return Err(adk_core::AdkError::Tool(
            format!("Unknown operation: {}", operation)
        )),
    };

    Ok(json!({ "result": result }))
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let api_key = std::env::var("GOOGLE_API_KEY")?;
    let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.5-flash")?);

    // Create calculator tool
    let calc_tool = FunctionTool::new(
        "calculator",
        "Performs arithmetic: add, multiply, percent.          Args: operation (string), a (number), b (number)",
        calculator,
    );

    // Math Expert agent - has its own tools
    let math_agent = LlmAgentBuilder::new("math_expert")
        .description(
            "A math expert that performs calculations. Use for any              math-related questions, percentages, or numerical analysis."
        )
        .instruction(
            "You are a math expert. Use the calculator tool for              calculations. Show your work step by step. Be precise."
        )
        .model(model.clone())
        .tool(Arc::new(calc_tool))
        .build()?;

    // Trivia Expert agent - uses LLM knowledge
    let trivia_agent = LlmAgentBuilder::new("trivia_expert")
        .description(
            "A trivia and history expert. Use for questions about              historical facts, pop culture, science facts, and trivia."
        )
        .instruction(
            "You are a trivia expert with vast knowledge across domains.              Answer questions accurately and include interesting related facts."
        )
        .model(model.clone())
        .build()?;

    // Wrap agents as tools with configuration
    let math_tool = AgentTool::new(Arc::new(math_agent))
        .skip_summarization(false)   // Summarize lengthy responses
        .forward_artifacts(true);     // Pass through any generated files

    let trivia_tool = AgentTool::new(Arc::new(trivia_agent))
        .skip_summarization(false);

    // Coordinator uses agents as tools
    let coordinator = LlmAgentBuilder::new("coordinator")
        .description("Smart assistant that combines expert knowledge")
        .instruction(
            "You are a helpful assistant with access to expert agents:\n             - math_expert: For calculations and math problems\n             - trivia_expert: For facts, history, and trivia\n\n             When questions span multiple domains, call multiple experts              and synthesize their responses into a cohesive answer."
        )
        .model(model)
        .tool(Arc::new(math_tool))
        .tool(Arc::new(trivia_tool))
        .build()?;

    Launcher::new(Arc::new(coordinator)).run().await?;
    Ok(())
}

Example: Multi-Domain Question

User: What is 15% of 250, and is that number significant in history?

System: // Coordinator calls math_expert tool

[math_expert responds]: 15% of 250 is 37.5

System: // Coordinator calls trivia_expert tool

[trivia_expert responds]: 37 and 38 are less historically notable, but 37.5°C is human body temperature...

System: // Coordinator synthesizes

[coordinator]: 15% of 250 equals 37.5. Interestingly, 37.5°C (99.5°F) is close to the average human body temperature of 37°C, making it a medically significant number!

✅ When to Use AgentTool

  • You need to combine responses from multiple experts
  • Coordinator should summarize or filter specialist output
  • Specialists have their own tools (nested capabilities)
  • You want programmatic control over agent invocation

5. Pattern 3: The Supervisor Graph

🎯 Use Case: Content Creation Pipeline

You're building a content creation system. Given a topic, you need to: (1) research it, (2) write an article, (3) add code examples. The supervisor dynamically decides the order based on the task, and workers can cycle back for revisions.

The Supervisor Graph pattern uses ADK-Rust's graph-based workflow system. A supervisor agent dynamically routes to workers, with full state management and cyclical execution support.

STARTSupervisorDecides nextworker agentResearcherWorkerWriterWorkerCoderWorkerENDcycle back"done" → finalize → ENDShared State• research_output• written_content• code_output

Why Use a Graph?

  • Dynamic routing: Supervisor decides next worker based on current state
  • Cyclical execution: Workers can loop back for iterations
  • Shared state: All nodes read/write to a common state object
  • Conditional edges: Different paths based on LLM decisions
  • Recursion limits: Prevent infinite loops

Implementation

use adk_agent::LlmAgentBuilder;
use adk_graph::{
    StateGraph,
    edge::{START, END},
    node::{AgentNode, ExecutionConfig, NodeOutput},
    state::State,
};
use adk_model::GeminiModel;
use serde_json::json;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let api_key = std::env::var("GOOGLE_API_KEY")?;
    let model = Arc::new(GeminiModel::new(&api_key, "gemini-2.0-flash")?);

    // Supervisor: Decides which worker should act next
    let supervisor = LlmAgentBuilder::new("supervisor")
        .description("Routes tasks to specialized workers")
        .instruction(
            "You are a task supervisor. Based on the task and work done so far,              decide who should work next.\n\n             Workers available:\n             - researcher: Gathers information and facts\n             - writer: Writes content based on research\n             - coder: Creates code examples\n\n             Respond with ONLY one word: 'researcher', 'writer', 'coder', or 'done'."
        )
        .model(model.clone())
        .build()?;

    // Workers with specialized roles
    let researcher = LlmAgentBuilder::new("researcher")
        .instruction("Research the topic. Provide key facts as bullet points.")
        .model(model.clone())
        .build()?;

    let writer = LlmAgentBuilder::new("writer")
        .instruction("Write engaging content based on the research provided.")
        .model(model.clone())
        .build()?;

    let coder = LlmAgentBuilder::new("coder")
        .instruction("Write clean, documented code examples for the topic.")
        .model(model.clone())
        .build()?;

    // Create AgentNodes with input/output mappers
    let supervisor_node = AgentNode::new(Arc::new(supervisor))
        .with_input_mapper(|state| {
            let task = state.get("task").and_then(|v| v.as_str()).unwrap_or("");
            let history = state.get("history")
                .and_then(|v| v.as_array())
                .map(|arr| arr.iter()
                    .filter_map(|h| h.get("agent").and_then(|a| a.as_str()))
                    .map(|s| format!("- {} completed", s))
                    .collect::<Vec<_>>()
                    .join("\n"))
                .unwrap_or_default();

            adk_core::Content::new("user").with_text(format!(
                "Task: {}\n\nWork completed:\n{}\n\nWho next?",
                task,
                if history.is_empty() { "None yet" } else { &history }
            ))
        })
        .with_output_mapper(|events| {
            let mut updates = std::collections::HashMap::new();
            for event in events {
                if let Some(content) = event.content() {
                    let text: String = content.parts.iter()
                        .filter_map(|p| p.text())
                        .collect();
                    let next = if text.to_lowercase().contains("researcher") {
                        "researcher"
                    } else if text.to_lowercase().contains("writer") {
                        "writer"
                    } else if text.to_lowercase().contains("coder") {
                        "coder"
                    } else {
                        "done"
                    };
                    updates.insert("next_agent".to_string(), json!(next));
                }
            }
            updates
        });

    // Build the graph
    let graph = StateGraph::with_channels(&[
        "task", "next_agent", "history",
        "research_output", "written_content", "code_output"
    ])
    .add_node(supervisor_node)
    .add_node(AgentNode::new(Arc::new(researcher)))
    .add_node(AgentNode::new(Arc::new(writer)))
    .add_node(AgentNode::new(Arc::new(coder)))
    // Finalize node compiles all outputs
    .add_node_fn("finalize", |ctx| async move {
        let research = ctx.get("research_output").and_then(|v| v.as_str());
        let content = ctx.get("written_content").and_then(|v| v.as_str());
        let code = ctx.get("code_output").and_then(|v| v.as_str());

        let result = format!(
            "=== FINAL OUTPUT ===\n\n{}\n\n{}\n\n{}",
            research.unwrap_or("No research"),
            content.unwrap_or("No content"),
            code.unwrap_or("No code")
        );
        Ok(NodeOutput::new().with_update("final_result", json!(result)))
    })
    // Graph structure
    .add_edge(START, "supervisor")
    .add_conditional_edges(
        "supervisor",
        |state| state.get("next_agent")
            .and_then(|v| v.as_str())
            .unwrap_or("done")
            .to_string(),
        [
            ("researcher", "researcher"),
            ("writer", "writer"),
            ("coder", "coder"),
            ("done", "finalize"),
        ],
    )
    // Workers cycle back to supervisor
    .add_edge("researcher", "supervisor")
    .add_edge("writer", "supervisor")
    .add_edge("coder", "supervisor")
    .add_edge("finalize", END)
    .compile()?
    .with_recursion_limit(15);  // Prevent infinite loops

    // Execute
    let mut input = State::new();
    input.insert("task".to_string(), json!("Create a guide about Rust error handling"));
    input.insert("history".to_string(), json!([]));

    let result = graph.invoke(input, ExecutionConfig::new("content-thread")).await?;
    println!("{}", result.get("final_result").and_then(|v| v.as_str()).unwrap_or(""));

    Ok(())
}

✅ When to Use Supervisor Graph

  • Workflow order is dynamic and LLM-determined
  • Workers may need to iterate or cycle back
  • Complex state needs to be shared between agents
  • You need checkpointing or resumable workflows
  • Task decomposition requires multiple sequential steps

6. Pattern Comparison

FeatureCoordinatorAgentToolSupervisor Graph
User talks toSpecialist directlyCoordinator onlyFinal output
Multi-agent calls❌ One at a time✅ Parallel possible✅ Orchestrated
Response processing
Cyclic workflows
Shared stateSession onlySession onlyFull graph state
Setup complexity🟢 Low🟡 Medium🔴 High

7. Conclusion

Multi-agent systems let you build sophisticated AI applications by combining specialized agents. Choose your pattern based on your needs:

  • Coordinator: Quick to set up, great for customer service routing
  • AgentTool: When you need to process or combine responses
  • Supervisor Graph: Complex, dynamic, multi-step workflows

🦀 Get Started

Ready to build your own multi-agent system?