Delegation
A tool is a Ruby method your model can call. A delegation is another agent your model can call.
Same mechanism, different unit of work: the callee has its own instructions, its own templates, its own model, and its own budget. That separation is the whole point. A specialist agent stays specialist, and the generalist orchestrating it never inherits its prompt.
Quick Start
Declare what the sub-agent exposes, then delegate to it:
class TicketClassifierAgent < ApplicationAgent
generate_with :openai, model: "gpt-4o-mini"
delegation :classify, description: "Classify a support ticket by topic and urgency" do
string :body, required: true, description: "The customer's message, verbatim"
returns do
string :category, required: true, enum: %w[billing bug account other],
description: "What the ticket is about"
string :urgency, required: true, enum: %w[low normal high],
description: "How quickly it needs a human"
end
end
def classify(body:)
prompt(message: body)
end
endclass TriageAgent < ApplicationAgent
generate_with :openai, model: "gpt-4o"
# Ceiling for every delegation this agent makes in one generation.
delegation_budget max_calls: 6, max_duration: 45
delegate_to TicketClassifierAgent, budget: { max_calls: 1, timeout: 10 }
delegate_to KnowledgeBaseAgent, as: :search_help_centre,
budget: { max_calls: 3, max_tokens: 20_000 }
def triage(ticket:)
prompt(message: "Triage this ticket. Classify it first, then find the article that answers it.\n\n#{ticket}")
end
enddelegate_to turns every contract the sub-agent declares into a tool. Nothing else changes: the delegated tools sit alongside the action's own tools:, and the provider routes calls to them exactly as it routes any other tool call.
Three Declarations, Three Owners
Delegation has three moving parts, and each is declared where the knowledge actually lives:
| Part | Declared on | Why there |
|---|---|---|
| The contract — inputs, outputs, description | the sub-agent | It changes when the action changes. Callers never restate it. |
| The budget — calls, tokens, cost, latency | the call site | Only the caller knows what the work is worth. |
| The backend — provider, model, sampling | the call site | The same sub-agent is worth different silicon in different parents. |
Contracts
delegation declares one action: what it accepts, and optionally what it returns.
class TranslatorAgent < ApplicationAgent
generate_with :openai, model: "gpt-4o-mini"
delegation :translate, description: "Translate text into a target language" do
string :text, required: true, description: "Text to translate"
string :locale, required: true, description: "BCP 47 target locale, e.g. pt-BR"
end
def translate(text:, locale:)
prompt(message: "Translate into #{locale}:\n\n#{text}")
end
endThe description is the only thing a calling model reads before deciding whether to hand work over. Write it for someone who has never seen the code.
Schema DSL
Inside a delegation block:
delegation :search, description: "Search the product catalogue" do
string :query, required: true, description: "What the customer is looking for"
integer :limit, description: "Maximum results (default 10)"
number :max_price, description: "Upper price bound in USD"
boolean :in_stock_only
string :sort, enum: %w[relevance price rating], description: "Result ordering"
array :categories, of: :string, description: "Restrict to these categories"
array :filters do # array of objects
string :field, required: true
string :value, required: true
end
object :shipping do # nested object
string :country, required: true, description: "ISO 3166-1 alpha-2"
end
endEvery keyword you pass beyond required: and description: lands in the JSON Schema verbatim, so enum, format, minimum, pattern and friends all work.
Reusing an existing schema
A contract can take a JSON Schema hash, or any class that responds to to_json_schema — which includes anything using ActiveAgent::SchemaGenerator:
delegation :create, description: "Draft a support ticket", schema: TicketForm
delegation :notify, description: "Send a notification", schema: {
type: "object",
properties: { channel: { type: "string" } },
required: [ "channel" ]
}Declared outputs
returns declares the shape the action answers with. ActiveAgent turns it into the sub-agent's response_format, parses the answer, and checks the required keys before handing it back — so the calling agent receives data, not a blob of text it has to re-parse.
delegation :classify, description: "Classify a support ticket by topic and urgency" do
string :body, required: true, description: "The customer's message, verbatim"
returns do
string :category, required: true, enum: %w[billing bug account other]
string :urgency, required: true, enum: %w[low normal high]
end
endThe delegated call now returns { category: "billing", urgency: "high" }.
When the model returns something that misses a required key, the caller gets a structured invalid_result it can act on rather than an exception:
{ error: "invalid_result", missing: [ "urgency" ], message: "...", content: "..." }Pass on_invalid: :raise to the contract if you would rather the generation fail loudly.
Delegating to an agent you do not own
Declare the contract at the call site instead:
delegate_to Vendor::ClassifierAgent, action: :classify,
description: "Classify a support ticket" do
string :body, required: true, description: "Ticket body"
endBudgets
A sub-agent is a loop inside a loop. The parent model decides how often to call it, and each call spends tokens and wall-clock time nobody explicitly authorized. A budget puts a ceiling on that.
delegation_budget max_calls: 6, max_duration: 45 # every delegation, together
delegate_to TicketClassifierAgent, budget: { max_calls: 1, timeout: 10 }
delegate_to KnowledgeBaseAgent, budget: { max_calls: 3, max_tokens: 20_000 }Both apply — a call has to clear the agent-wide ceiling and its own limit.
activeagent/test/docs/actions/delegation_examples_test.rb:85
{
"agent": {
"max_calls": 6,
"max_duration": 45
},
"classify": {
"max_calls": 1,
"timeout": 10
},
"search_help_centre": {
"max_calls": 3,
"max_tokens": 20000
}
}| Limit | Unit | Meaning |
|---|---|---|
max_calls | count | Delegated invocations |
max_tokens | tokens | Cumulative tokens the sub-agent spent |
max_cost | USD | Cumulative spend — needs rates |
max_duration | seconds | Cumulative wall-clock across delegated calls |
timeout | seconds | Wall-clock ceiling for a single call |
on_exceeded | :stop / :raise | What happens at the ceiling |
rates | hash | Inline token prices for max_cost |
Budgets are scoped to one generation. The ledger lives on the agent instance, which ActiveAgent creates fresh for every generation, so there is nothing to reset and no cross-request bleed.
Exhausting a budget
By default (on_exceeded: :stop), the delegation stops and the calling model is told why, in terms it can act on:
activeagent/test/docs/actions/delegation_examples_test.rb:139
{
"error": "budget_exceeded",
"limit": "max_calls",
"allowed": 1,
"used": 1,
"message": "Delegation budget exhausted (max_calls: 1 of 1 used). Do not retry this tool; answer with the information you already have."
}The generation keeps going and the model answers with what it already has. That is almost always what you want: a bounded answer beats a raised exception halfway through a conversation.
When you would rather fail loudly, on_exceeded: :raise raises ActiveAgent::Delegation::BudgetExceededError, which carries the violated limit:
delegate_to KnowledgeBaseAgent, budget: { max_calls: 3, on_exceeded: :raise }
rescue ActiveAgent::Delegation::BudgetExceededError => error
error.violation.limit #=> :max_calls
error.violation.allowed #=> 3
error.violation.used #=> 3Since token spend can only be measured after a call, limits are checked before each call: max_tokens: 8_000 means "stop delegating once 8,000 tokens have been spent", not "never exceed 8,000 tokens".
Cost budgets
ActiveAgent ships no built-in price list — vendor pricing moves faster than gem releases, and a stale table silently under-reports spend. Register the rates you actually pay, in USD per one million tokens:
# config/initializers/active_agent.rb
ActiveAgent::Delegation::Pricing.register("gpt-4o-mini", input: 0.15, output: 0.60)
ActiveAgent::Delegation::Pricing.register(/\Aclaude-haiku/, input: 1.00, output: 5.00)String patterns match by prefix, so "gpt-4o-mini" also covers "gpt-4o-mini-2024-07-18". Or state rates on a single budget:
delegate_to SummarizerAgent, budget: { max_cost: 0.05, rates: { input: 0.15, output: 0.60 } }A model with no known rates contributes 0.0 to the cost ledger rather than a guess, so max_cost never fires on invented numbers.
Inspecting spend
Every ledger is readable after a generation:
agent = TriageAgent.new
agent.process(:triage, ticket: ticket)
agent.process_prompt
agent.delegation_ledger.to_h #=> { calls: 3, tokens: 4_120, cost: 0.0009, duration: 5.2 }
agent.delegation_ledger_for(:classify).to_h #=> { calls: 1, tokens: 380, cost: 0.0001, duration: 0.7 }Swappable Backends
A sub-agent's contract is separate from what serves it. The same agent can run on a small local model inside one parent and a frontier model inside another — and neither agent's code changes when you move it.
class LocalTriageAgent < TriageAgent
# Same classifier, same contract, different silicon.
delegate_to TicketClassifierAgent, backend: { provider: :ollama, model: "gpt-oss:20b" }
enddelegate_to SummarizerAgent, backend: { model: "gpt-4o-mini", temperature: 0 } # same provider
delegate_to SummarizerAgent, backend: :ollama # different provider
delegate_to SummarizerAgent, backend: { provider: :anthropic, model: "claude-haiku-4-5" }Changing the provider rebuilds provider configuration — host, credentials, service — rather than merging a hash over the old one, so nothing leaks between vendors. Template lookup still resolves to the original agent's views.
Backend options are applied after the sub-agent's own action runs, so the call site wins. That matches ActiveAgent's precedence everywhere else: runtime > agent class > config/active_agent.yml.
Scoping Delegations Per Action
By default every declared delegation is offered on every action. Narrow it with the delegations: prompt option:
def triage(ticket:)
prompt(message: ticket) # every delegation
end
def acknowledge(ticket:)
prompt(message: ticket, delegations: false) # none — just answer
end
def route(ticket:)
prompt(message: ticket, delegations: [ :classify ]) # one
endTesting Delegations
Two properties make delegations easy to test.
They are ordinary methods. delegate_to defines a real instance method, so you can call one directly with no model in the loop:
result = TestTriageAgent.new.search_help_centre(question: "How do I reset my password?")The backend is swappable. Point a sub-agent at the Mock provider in tests and the contract stays exactly as production has it:
class TestTriageAgent < TriageAgent
# Point the sub-agent at the mock provider; the contract is unchanged.
delegate_to KnowledgeBaseAgent, as: :search_help_centre, backend: :mock
endactiveagent/test/docs/actions/delegation_examples_test.rb:120
Answerway omfray ethay elphay entrecay, itingcay upway otay 5ay articleway itlestay: Owhay oday Iway esetray myay asswordpay?And what the calling model actually sees is plain data you can assert on:
activeagent/test/docs/actions/delegation_examples_test.rb:70
[
{
"name": "classify",
"description": "Classify a support ticket by topic and urgency",
"parameters": {
"type": "object",
"properties": {
"body": {
"type": "string",
"description": "The customer's message, verbatim"
}
},
"required": [
"body"
],
"additionalProperties": false
}
},
{
"name": "search_help_centre",
"description": "Find the help-centre article that answers a question",
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The customer's question in plain language"
},
"limit": {
"type": "integer",
"description": "How many articles to consider (default 5)"
}
},
"required": [
"question"
],
"additionalProperties": false
}
}
]Instrumentation
Every delegated call emits delegate.active_agent with the agent, sub-agent, action, tool name, arguments, resolved model, duration, usage, cost and the running ledger. Refusals emit delegation_refused.active_agent with the violated limit.
ActiveSupport::Notifications.subscribe("delegate.active_agent") do |*, payload|
Rails.logger.info(
"#{payload[:agent]} → #{payload[:delegate]}##{payload[:action]} " \
"#{payload[:duration_ms]}ms #{payload[:usage]&.total_tokens} tokens"
)
endSee Instrumentation for the full event catalogue.
Provider Support
Delegation is built on function calling, so it works wherever tools do. Declared returns schemas additionally use structured output, which is better supported on some providers than others — a contract whose provider ignores response_format still validates the parsed answer and reports invalid_result when it does not fit.
Reference
delegation
| Option | Type | Purpose |
|---|---|---|
description: | String | Required. What the action does, for the calling model |
schema: | Hash, Class, Schema | Inputs, when not using the block DSL |
returns: | Hash, Class, Schema | Declared output shape |
budget: | Hash | Default budget callers inherit |
on_invalid: | :error / :raise | When output misses required keys |
delegate_to
| Option | Type | Purpose |
|---|---|---|
only: / except: | Symbol, Array | Which of the sub-agent's contracts to expose |
as: | Symbol | Rename the tool the model sees |
action: | Symbol | Declare a contract here instead of on the sub-agent |
description: / schema: / returns: | — | Inline contract, used with action: |
backend: | Symbol, Hash | Provider and options this delegation runs on |
budget: | Hash | Limits for this delegation |
params: | Hash, Symbol, Proc | Params forwarded to the sub-agent |
params: accepts a Hash, the name of a method on the delegating agent, or a Proc evaluated against it:
delegate_to KnowledgeBaseAgent, params: { locale: "en" }
delegate_to KnowledgeBaseAgent, params: :knowledge_base_params
delegate_to KnowledgeBaseAgent, params: -> { { account_id: params[:account_id] } }Troubleshooting
The model never delegates. The description is the only thing it reads. Say what the sub-agent does and when to use it, not what it is. tool_choice: "required" forces a hand-off.
already responds to #name. The tool name collides with an existing method on the delegating agent. Rename it with as:.
does not declare any delegations. The sub-agent has no delegation macro. Add one to it, or declare the contract at the call site with action:.
Arguments the model invented are dropped. Anything outside the declared schema is discarded before the sub-agent's method is called, so a hallucinated key produces a working call rather than an ArgumentError. If a real parameter is being dropped, it is missing from the contract.
Related Documentation
- Tools — the function-calling layer delegation is built on
- Structured Output — how declared
returnsschemas reach the provider - Instrumentation — subscribing to delegation events
- Mock Provider — running delegations offline in tests
- Generation — executing delegation-enabled generations
- Configuration — provider configuration backends resolve against