Building Multi-Agent Systems
Multi-agent systems allow you to decompose complex problems into specialized agents that collaborate to provide comprehensive solutions. This guide covers patterns and best practices for designing effective multi-agent workflows.
Core Patterns
Hub-and-Spoke Architecture
The most common and stable pattern is hub-and-spoke, where a central triage agent routes conversations to specialized agents:
# Specialized agents
billing_agent = Agents::Agent.new(
name: "Billing",
instructions: "Handle billing inquiries, payment processing, and account issues."
)
support_agent = Agents::Agent.new(
name: "Support",
instructions: "Provide technical troubleshooting and product support."
)
# Central hub agent
triage_agent = Agents::Agent.new(
name: "Triage",
instructions: "Route users to the appropriate specialist. Only hand off, don't solve problems yourself."
)
# Register handoffs (one-way: triage -> specialists)
triage_agent.register_handoffs(billing_agent, support_agent)
# Create runner with triage as entry point
runner = Agents::AgentRunner.with_agents(triage_agent, billing_agent, support_agent)
Dynamic Instructions
Use Proc-based instructions to create context-aware agents:
support_agent = Agents::Agent.new(
name: "Support",
instructions: ->(context) {
customer_tier = context[:customer_tier] || "standard"
<<~INSTRUCTIONS
You are a technical support specialist for #{customer_tier} tier customers.
#{customer_tier == "premium" ? "Provide priority white-glove service." : ""}
Available tools: diagnostics, escalation
INSTRUCTIONS
}
)
Agent Design Principles
Clear Boundaries
Each agent should have a distinct, non-overlapping responsibility:
# GOOD: Clear specialization
sales_agent = Agents::Agent.new(
name: "Sales",
instructions: "Handle product inquiries, pricing, and purchase decisions. Transfer technical questions to support."
)
support_agent = Agents::Agent.new(
name: "Support",
instructions: "Handle technical issues and product troubleshooting. Transfer sales questions to sales team."
)
Avoiding Handoff Loops
Design instructions to prevent infinite handoffs:
# BAD: Conflicting handoff criteria
agent_a = Agents::Agent.new(
instructions: "Handle X. If you need Y info, hand off to Agent B."
)
agent_b = Agents::Agent.new(
instructions: "Handle Y. If you need X context, hand off to Agent A."
)
# GOOD: Clear escalation hierarchy
specialist = Agents::Agent.new(
instructions: "Handle specialized requests. Ask users for needed info directly."
)
triage = Agents::Agent.new(
instructions: "Route users to specialists. Don't solve problems yourself."
)
Conversation Flow Management
Entry Points
The first agent in AgentRunner.with_agents()
becomes the default entry point:
# Triage agent handles all initial conversations
runner = Agents::AgentRunner.with_agents(triage_agent, billing_agent, support_agent)
# Start conversation
result = runner.run("I need help with my account")
# -> Automatically starts with triage_agent
Context Preservation
The AgentRunner automatically maintains conversation history across handoffs:
# First interaction
result1 = runner.run("My name is John and I have a billing question")
# -> Triage agent hands off to billing agent
# Continue conversation
result2 = runner.run("What payment methods do you accept?", context: result1.context)
# -> Billing agent remembers John's name and previous context
Handoff Detection
The system automatically determines the current agent from conversation history:
# No need to manually specify which agent to use
result = runner.run("Follow up question", context: previous_result.context)
# -> AgentRunner automatically selects correct agent based on conversation state
Advanced Patterns
Tool-Specific Agents
Create agents specialized for particular tool categories:
data_agent = Agents::Agent.new(
name: "DataAnalyst",
instructions: "Analyze data and generate reports using available analytics tools.",
tools: [DatabaseTool.new, ChartGeneratorTool.new, ReportTool.new]
)
communication_agent = Agents::Agent.new(
name: "Communications",
instructions: "Handle notifications and external communications.",
tools: [EmailTool.new, SlackTool.new, SMSTool.new]
)
Conditional Handoffs
Use dynamic instructions to control handoff behavior:
triage_agent = Agents::Agent.new(
name: "Triage",
instructions: ->(context) {
business_hours = context[:business_hours] || false
base_instructions = "Route users to appropriate departments."
if business_hours
base_instructions + " All departments are available."
else
base_instructions + " Only hand off urgent technical issues to support. Others should wait for business hours."
end
}
)
Testing Multi-Agent Systems
Unit Testing Individual Agents
Test each agent in isolation:
RSpec.describe "BillingAgent" do
let(:agent) { create_billing_agent }
let(:runner) { Agents::AgentRunner.with_agents(agent) }
it "handles payment inquiries" do
result = runner.run("What payment methods do you accept?")
expect(result.output).to include("credit card", "bank transfer")
end
end
Integration Testing Handoffs
Test complete workflows:
RSpec.describe "Customer Support Workflow" do
let(:runner) { create_support_runner } # Creates triage + specialists
it "routes billing questions correctly" do
result = runner.run("I have a billing question")
# Verify handoff occurred
expect(result.context[:current_agent]).to eq("Billing")
# Test continued conversation
followup = runner.run("What are your payment terms?", context: result.context)
expect(followup.output).to include("payment terms")
end
end
Common Pitfalls
Over-Specialization
Don’t create too many narrow agents - it increases handoff complexity and latency.
Under-Specified Instructions
Vague instructions lead to inappropriate handoffs. Be explicit about what each agent should and shouldn’t handle.
Circular Dependencies
Avoid mutual handoffs between agents. Use hub-and-spoke or clear hierarchical patterns instead.
Context Leakage
Don’t rely on shared mutable state. Use the context hash for inter-agent communication.
Performance Considerations
Handoff Overhead
Each handoff adds latency. Design agents to minimize unnecessary transfers.
Context Size
Large contexts increase token usage. Clean up irrelevant data periodically:
# Remove old conversation history
cleaned_context = result.context.dup
cleaned_context[:conversation_history] = cleaned_context[:conversation_history].last(10)
Concurrent Execution
AgentRunner is thread-safe, allowing multiple conversations simultaneously:
# Safe to use same runner across threads
threads = users.map do |user|
Thread.new do
user_result = runner.run(user.message, context: user.context)
# Handle result...
end
end