Skip to content

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:

ruby
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
end
ruby
class 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
end

delegate_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:

PartDeclared onWhy there
The contract — inputs, outputs, descriptionthe sub-agentIt changes when the action changes. Callers never restate it.
The budget — calls, tokens, cost, latencythe call siteOnly the caller knows what the work is worth.
The backend — provider, model, samplingthe call siteThe same sub-agent is worth different silicon in different parents.

Contracts ​

delegation declares one action: what it accepts, and optionally what it returns.

ruby
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
end

The 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:

ruby
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
end

Every 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:

ruby
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.

ruby
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
end

The 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:

ruby
{ 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:

ruby
delegate_to Vendor::ClassifierAgent, action: :classify,
            description: "Classify a support ticket" do
  string :body, required: true, description: "Ticket body"
end

Budgets ​

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.

ruby
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

json
{
  "agent": {
    "max_calls": 6,
    "max_duration": 45
  },
  "classify": {
    "max_calls": 1,
    "timeout": 10
  },
  "search_help_centre": {
    "max_calls": 3,
    "max_tokens": 20000
  }
}
LimitUnitMeaning
max_callscountDelegated invocations
max_tokenstokensCumulative tokens the sub-agent spent
max_costUSDCumulative spend — needs rates
max_durationsecondsCumulative wall-clock across delegated calls
timeoutsecondsWall-clock ceiling for a single call
on_exceeded:stop / :raiseWhat happens at the ceiling
rateshashInline 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

json
{
  "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:

ruby
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     #=> 3

Since 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:

ruby
# 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:

ruby
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:

ruby
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.

ruby
class LocalTriageAgent < TriageAgent
  # Same classifier, same contract, different silicon.
  delegate_to TicketClassifierAgent, backend: { provider: :ollama, model: "gpt-oss:20b" }
end
ruby
delegate_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:

ruby
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
end

Testing 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:

ruby
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:

ruby
class TestTriageAgent < TriageAgent
  # Point the sub-agent at the mock provider; the contract is unchanged.
  delegate_to KnowledgeBaseAgent, as: :search_help_centre, backend: :mock
end

activeagent/test/docs/actions/delegation_examples_test.rb:120

ruby
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

json
[
  {
    "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.

ruby
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"
  )
end

See 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 ​

OptionTypePurpose
description:StringRequired. What the action does, for the calling model
schema:Hash, Class, SchemaInputs, when not using the block DSL
returns:Hash, Class, SchemaDeclared output shape
budget:HashDefault budget callers inherit
on_invalid::error / :raiseWhen output misses required keys

delegate_to ​

OptionTypePurpose
only: / except:Symbol, ArrayWhich of the sub-agent's contracts to expose
as:SymbolRename the tool the model sees
action:SymbolDeclare a contract here instead of on the sub-agent
description: / schema: / returns:—Inline contract, used with action:
backend:Symbol, HashProvider and options this delegation runs on
budget:HashLimits for this delegation
params:Hash, Symbol, ProcParams forwarded to the sub-agent

params: accepts a Hash, the name of a method on the delegating agent, or a Proc evaluated against it:

ruby
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.