Skip to content

Agent-as-tool delegation as a core primitive - #360

Merged
TonsOfFun merged 1 commit into
mainfrom
claude/activeagent-agent-as-tool-fc3nvg
Aug 14, 2026
Merged

Agent-as-tool delegation as a core primitive#360
TonsOfFun merged 1 commit into
mainfrom
claude/activeagent-agent-as-tool-fc3nvg

Conversation

@TonsOfFun

Copy link
Copy Markdown
Contributor

Implements item 4 of docs/framework/v2-extraction-roadmap.md:

Agent-to-agent delegation. tools_function only routes back to self. The platform built call_agent (sub-agent invocation with a Thread.current depth cap) as an app tool. v2: a first-class delegation primitive — invoke another agent class/instance as a tool, with depth limits and shared trace/context correlation.

It also covers the remainder of item 3 ("a token/cost budget alongside the turn cap"). The code this supersedes is AgentExecutionService#call_agent in the platform app — MAX_CALL_DEPTH = 2, guarded by a thread-local counter.

What this is

A tool is a Ruby method the model can call. A delegation is another agent the model can call. The callee keeps its own instructions, templates, model and budget, so a specialist agent stays specialist and the generalist orchestrating it never inherits its prompt.

Three declarations, each where the knowledge lives:

The contract lives on the sub-agent.

class SummarizerAgent < ApplicationAgent
  generate_with :openai, model: "gpt-4o-mini"

  delegation :summarize, description: "Condense a document into key points" do
    string  :text, required: true, description: "Full document text"
    integer :limit, description: "Maximum number of key points"

    returns do
      string :summary, required: true
      array  :points, of: :string, required: true
    end
  end

  def summarize(text:, limit: 5)
    prompt(message: "Summarize in #{limit} points: #{text}")
  end
end

Inputs come from a block DSL, a plain JSON Schema hash, or any class responding to to_json_schema. A declared returns becomes the sub-agent's response_format; its answer is parsed and checked against the required keys before the caller sees it, so callers receive data rather than text to re-parse.

The budget lives at the call site, because only the caller knows what the work is worth.

class ResearchAgent < ApplicationAgent
  delegation_budget max_calls: 8, max_duration: 60

  delegate_to SummarizerAgent, budget: { max_calls: 3, timeout: 20 }
  delegate_to FactCheckAgent, as: :verify,
              backend: { provider: :anthropic, model: "claude-haiku-4-5" }
end

max_calls, max_tokens, max_cost, max_duration and a per-call timeout. Exhausting one returns a structured result the model can reason about rather than raising mid-conversation:

{ error: "budget_exceeded", limit: "max_calls", allowed: 3, used: 3,
  message: "Delegation budget exhausted... answer with the information you already have." }

on_exceeded: :raise is available. Ledgers live on the agent instance, so a budget is scoped to one generation with nothing to reset.

The backend lives at the call site too. Provider swaps go through a cached subclass configured by generate_with rather than merging a hash over stale provider config, and the subclass reports its parent's name so template lookup still resolves to the original agent's views.

Notes for review

  • Delegation::Pricing ships with an empty table on purpose. Vendor pricing moves faster than gem releases and a stale table silently under-reports spend, so apps register their own rates or state them inline on a budget. The tradeoff: max_cost does nothing until someone registers rates. (solid_agent's ModelPricing already solves this better — if the two gems should share it, that is worth discussing here.)
  • One line outside the feature. prepare_prompt_parameters now writes the trace id back into prompt_options (||= instead of ||) so delegated sub-agents inherit it and a delegation tree reads as one trace. The telemetry instrumentation already did this write-back when enabled.
  • Delegated tools merge into the action's own tools:; scope per action with delegations: false or delegations: [:name].

Verification

  • 54 new tests covering the schema DSL, contract errors, tool wiring, the real provider tool loop end-to-end (scripted mock provider, no network), every budget limit, timeouts, backend swaps, structured returns and their failure path, ledgers, pricing, instrumentation and trace inheritance.
  • Full suite: 1341 runs, 0 failures. The 253 errors are pre-existing Missing credentials from integration tests needing API keys — confirmed identical on a stashed baseline before any of these changes.
  • Rubocop clean across all 382 files.

Open question

activeagents/solid_agent#2 ports this same work into solid_agent. I believe that one should be closed in favour of this PR: the roadmap's three-layer split puts execution in activeagent and persistence in solid_agent, and delegation is execution. Flagging it so the decision is explicit rather than implied by whichever merges first.


Generated by Claude Code

A tool is a Ruby method the model can call; a delegation is another agent
the model can call. The callee keeps its own instructions, templates, model
and budget, so a specialist agent stays specialist and the generalist
orchestrating it never inherits its prompt.

Three declarations, each where the knowledge lives:

- The contract lives on the sub-agent. `delegation :action, description:`
  declares the description the calling model reads, a JSON Schema for the
  inputs (block DSL, a plain hash, or any class responding to
  `to_json_schema`), and optionally a `returns` schema. A declared `returns`
  becomes the sub-agent's response_format; its answer is parsed and checked
  against the required keys before the caller sees it, so callers receive
  data rather than text to re-parse.

- The budget lives at the call site, because only the caller knows what the
  work is worth. `max_calls`, `max_tokens`, `max_cost`, `max_duration` and a
  per-call `timeout`, set per delegation and/or agent-wide with
  `delegation_budget`. Exhausting one returns a structured result the model
  can reason about instead of raising mid-conversation (`on_exceeded: :raise`
  is available). Ledgers live on the agent instance, so a budget is scoped to
  one generation with nothing to reset.

- The backend lives at the call site too. `backend: :ollama` or
  `backend: { provider: :anthropic, model: "claude-haiku-4-5" }` moves a
  delegation to different silicon without touching the sub-agent. Provider
  swaps go through a cached subclass configured by `generate_with` rather
  than merging a hash over stale provider config, and the subclass reports
  its parent's name so template lookup still resolves to the original views.

`delegate_to AgentClass` exposes the sub-agent's contracts as tools, with
only:/except:/as: for scoping and renaming, `params:` for forwarding, and
`action:` for declaring a contract at the call site when you don't own the
sub-agent. Delegated tools merge into the action's own `tools:`; scope them
per action with the `delegations:` prompt option.

Cost budgets read from `Delegation::Pricing`, which ships empty on purpose --
vendor pricing moves faster than gem releases, and a stale table silently
under-reports spend. Apps register the rates they pay, or state them inline
on a budget; an unpriced model contributes 0.0 rather than a guess.

Delegated calls emit `delegate.active_agent` and `delegation_refused.active_agent`,
and inherit the parent's trace id so a delegation tree is one trace. To make
that inheritance reliable, `prepare_prompt_parameters` now writes the
generation's trace id back into prompt_options instead of minting a throwaway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbmULyN7NyPwv62s7eWRmD
@TonsOfFun
TonsOfFun marked this pull request as ready for review August 14, 2026 22:09
@TonsOfFun
TonsOfFun merged commit b30691b into main Aug 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants