-
Notifications
You must be signed in to change notification settings - Fork 4
Routing
Classify user messages and route them to the right handler using LLM-powered classification.
The Routing concern adds classification capabilities to any BaseAgent subclass. Define routes with descriptions, and the agent automatically generates a classification prompt, sends it to the LLM, and returns a structured RoutingResult.
Key design decisions:
-
Thin concern, not a base class — Include
Routingin any agent, reusing all existing infrastructure - Pure classification — Returns a route symbol, never executes a target agent
- Zero duplication — Caching, reliability, retries, instrumentation all come free from BaseAgent
# app/agents/support_router.rb
class SupportRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :billing, "Billing, charges, refunds, payments"
route :technical, "Bugs, errors, crashes, technical issues"
route :sales, "Pricing, plans, upgrades, discounts"
default_route :general
endresult = SupportRouter.call(message: "I was charged twice")
result.route # => :billing
result.success? # => true
result.total_cost # => 0.00008Each route has a name (symbol) and a description that tells the LLM what messages belong to it:
class AppRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :billing, "Billing, invoices, charges, refunds, payment methods"
route :technical, "Bugs, errors, crashes, performance issues, technical support"
route :sales, "Pricing, plans, upgrades, discounts, enterprise inquiries"
route :account, "Password resets, profile changes, account settings"
default_route :general
endThe default_route is used when the LLM response doesn't match any defined route:
default_route :general # Default fallback
default_route :support # Custom name
default_route :general, agent: GeneralAgent # With agent mappingIf no default_route is set, it defaults to :general.
Optionally map routes to agent classes for downstream dispatch:
class AppRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :billing, "Billing questions", agent: BillingAgent
route :technical, "Technical issues", agent: TechnicalAgent
route :sales, "Sales inquiries", agent: SalesAgent
default_route :general, agent: GeneralAgent
end
result = AppRouter.call(message: "I was charged twice")
result.route # => :billing
result.agent_class # => BillingAgent
# Dispatch to the target agent yourself:
target_result = result.agent_class.ask(message)The router never auto-executes the target agent. You decide what to do with the route.
RoutingResult extends the standard Result with routing-specific fields:
result = SupportRouter.call(message: "I was charged twice")
# Routing fields
result.route # => :billing (Symbol)
result.agent_class # => BillingAgent or nil
result.raw_response # => "billing" (raw LLM text)
# Standard Result fields (all available)
result.success? # => true
result.total_cost # => 0.00008
result.input_tokens # => 85
result.output_tokens # => 3
result.duration_ms # => 280
result.model_id # => "gpt-4o-mini"
result.cached? # => false
# Serialization
result.to_h # => { route: :billing, agent_class: "BillingAgent", ... }By default, the system prompt is auto-generated from your route definitions:
You are a message classifier. Classify the user's message into exactly one
of the following categories:
- billing: Billing, charges, refunds, payments
- technical: Bugs, errors, crashes, technical issues
- sales: Pricing, plans, upgrades, discounts
- general: Default / general category
If none of the categories clearly match, classify as: general
Respond with ONLY the category name, nothing else.
Use the routing_system_prompt and routing_categories_text helpers in custom prompts:
class CustomRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :urgent, "Time-sensitive issues requiring immediate attention"
route :standard, "Normal priority requests"
route :feedback, "Product feedback, suggestions, feature requests"
default_route :standard
def system_prompt
<<~PROMPT
You are a priority classifier for Acme Corp's support team.
Classify the following message into one of these categories:
#{routing_categories_text}
If unsure, classify as: #{self.class.default_route_name}
PROMPT
end
endPass extra parameters to enrich the classification:
class ContextualRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :billing, "Billing, charges, refunds"
route :technical, "Bugs, errors, crashes"
default_route :general
param :customer_tier, required: false
param :locale, default: "en"
def system_prompt
base = routing_system_prompt
extras = []
extras << "The customer is on the #{customer_tier} tier." if customer_tier
extras << "The message is in #{locale}." if locale != "en"
extras.any? ? "#{base}\n\n#{extras.join("\n")}" : base
end
end
result = ContextualRouter.call(
message: "J'ai ete facture deux fois",
customer_tier: "enterprise",
locale: "fr"
)
result.route # => :billingFor one-off classifications without defining a class:
route = RubyLLM::Agents::Routing.classify(
message: "I was charged twice",
routes: {
billing: "Billing, charges, refunds",
technical: "Bugs, errors, crashes",
sales: "Pricing, plans, upgrades"
},
default: :general,
model: "gpt-4o-mini"
)
# => :billingThis creates an anonymous router class under the hood. For repeated use, define a class instead.
Cache identical classifications to avoid redundant API calls:
class CachedRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
cache_for 1.hour
route :billing, "Billing questions"
route :technical, "Technical issues"
default_route :general
endAdd retries and fallback models:
class ReliableRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
reliability do
retries max: 2, backoff: :exponential
fallback_models "gpt-3.5-turbo"
end
route :billing, "Billing questions"
route :technical, "Technical issues"
default_route :general
endRoute per-tenant:
result = SupportRouter.call(
message: "I was charged twice",
tenant: current_tenant
)Preview the classification prompt without making an API call:
result = SupportRouter.call(message: "test", dry_run: true)
result.content[:system_prompt] # => "You are a message classifier..."
result.content[:user_prompt] # => "test"Routes are inherited from parent classes:
class BaseRouter < ApplicationAgent
include RubyLLM::Agents::Routing
model "gpt-4o-mini"
temperature 0.0
route :billing, "Billing questions"
route :technical, "Technical issues"
default_route :general
end
class ExtendedRouter < BaseRouter
route :sales, "Sales inquiries" # Added to inherited routes
end
ExtendedRouter.routes.keys # => [:billing, :technical, :general, :sales]Router agents appear in the dashboard with:
- A cyan Router badge
- A dedicated routers tab in the agents list
- Route definitions displayed in the agent detail view
- Full execution history, cost tracking, and performance charts
RSpec.describe SupportRouter do
it "classifies billing messages" do
# Stub the pipeline to return a fake result
allow(RubyLLM::Agents::Pipeline::Executor).to receive(:execute) do |context|
context.output = RubyLLM::Agents::Routing::RoutingResult.new(
base_result: RubyLLM::Agents::Result.new(content: "billing", model_id: "gpt-4o-mini"),
route_data: { route: :billing, agent_class: nil, raw_response: "billing" }
)
context
end
result = described_class.call(message: "I was charged twice")
expect(result.route).to eq(:billing)
end
endTest process_response directly without any LLM call:
RSpec.describe SupportRouter do
let(:agent) { described_class.new(message: "test") }
it "parses clean route names" do
response = OpenStruct.new(content: "billing")
result = agent.process_response(response)
expect(result[:route]).to eq(:billing)
end
it "falls back to default for unknown responses" do
response = OpenStruct.new(content: "unknown_category")
result = agent.process_response(response)
expect(result[:route]).to eq(:general)
end
it "handles messy LLM output" do
response = OpenStruct.new(content: " **Billing** \n")
result = agent.process_response(response)
expect(result[:route]).to eq(:billing)
end
endRSpec.describe SupportRouter do
it "generates correct prompts" do
result = described_class.call(message: "test", dry_run: true)
expect(result.content[:system_prompt]).to include("billing")
expect(result.content[:user_prompt]).to eq("test")
end
endClass Methods (added via ClassMethods):
| Method | Description |
|---|---|
route(name, description, agent: nil) |
Define a classification route |
default_route(name, agent: nil) |
Set the default/fallback route |
routes |
Returns all defined routes (Hash) |
default_route_name |
Returns the default route name (Symbol) |
agent_type |
Returns :router
|
Instance Methods:
| Method | Description |
|---|---|
routing_system_prompt |
Auto-generated system prompt from routes |
routing_categories_text |
Formatted route list for custom prompts |
system_prompt |
Returns routing system prompt (overridable) |
user_prompt |
Returns the message parameter |
process_response(response) |
Parses LLM output to route hash |
Class Method:
| Method | Description |
|---|---|
Routing.classify(message:, routes:, default:, model:) |
Inline classification without a class |
Inherits from Result. Additional attributes:
| Attribute | Type | Description |
|---|---|---|
route |
Symbol | The classified route name |
agent_class |
Class/nil | Mapped agent class (if defined) |
raw_response |
String | Raw text from the LLM |