activeagent 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 867cf01603bb86b99c7abdbe6cc9e731264dc60bfdf3e89ca19a4d25fca5ea3d
4
- data.tar.gz: c8b86f8988ff27931f4a82f8aa71aa04168bceae00f8f9cab6b98507dd3d2b94
3
+ metadata.gz: 45044ca9e6ddabcdfb1be0f32074149a3e9fb8fcaa9e7c8a79c02f81c73eb64b
4
+ data.tar.gz: 7f0a2489a68e01f0dd0e9a1fb96878bb900acaab173ba0a0157ad82a8a510fe6
5
5
  SHA512:
6
- metadata.gz: 8c81f0537fddda60bbe3049cdb36b0478130d969ba0d35af408f3a5c527e2448d8e733638cd94b6c72c8f4b8d1ec13da32877baae3101e0373cc7d173253d06c
7
- data.tar.gz: 6af0f6a1879a9e291581a51beb5b6b96ac2ec641a9ebc007cb2345c695e1f746741fbb667bd761f889591d15c7ffeb917e7ecb7c5d1151f4d6687af3f87a3930
6
+ metadata.gz: 7c4797fc3bc8e97be8624ef989abc1486fe0d357f5248353dd09bc9852de605e0b14cebb33f3fb35ba166f62e64f7ce5522f62da94ad25b5b188a771f1f8aeec
7
+ data.tar.gz: 72bc8b89a042c1d3fd2489de0eb452d2e95106eaf9a46ad4dc5e8f1378b3441345c0b652c123490804943064f16526a1baa67584ce4b2a4315255beb3783203e
data/CHANGELOG.md CHANGED
@@ -5,7 +5,92 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.2.0] - Unreleased
8
+ ## [1.3.0] - 2026-08-18
9
+
10
+ ### Added
11
+
12
+ - **Agent-as-tool delegation.** A tool is a Ruby method the model can call; a
13
+ delegation is another agent it can call. The callee keeps its own
14
+ instructions, templates, model and budget, so a specialist agent stays
15
+ specialist and the generalist orchestrating it never inherits its prompt.
16
+ Declared with `delegation :action, description:` on the sub-agent, with a
17
+ JSON Schema for the inputs and an optional `returns` schema that becomes the
18
+ sub-agent's response format. See `docs/actions/delegation.md`.
19
+
20
+ ### Fixed
21
+
22
+ - **Streamed generations report their token usage.** A request with
23
+ `stream: true` recorded zero input and output tokens, and so zero cost and
24
+ no context-pressure estimate downstream — dashboards showed `Tokens 0` and
25
+ `$0.00` beside a run that had plainly called the API. Three things had to
26
+ hold at once for the usage to survive, and none did: the streaming path
27
+ returns `nil` rather than a response body to read usage from; Chat
28
+ Completions only emits its usage chunk when the request sets
29
+ `stream_options: {include_usage: true}`, which was never sent; and that
30
+ chunk arrives *after* `content.done`, where the response was already being
31
+ built. Completion now defers until the stream drains, and the usage chunk
32
+ is recorded on the way past. A provider hook (`api_stream_usage_parameters`,
33
+ empty by default) keeps providers that report unconditionally — or not at
34
+ all — unaffected.
35
+
36
+ Also fixed a silent conversion failure behind the same symptom:
37
+ `Usage.from_provider_usage` early-returns on anything that is not a Hash,
38
+ and the stainless gems hand back model objects, so usage was dropped even
39
+ when it did arrive.
40
+
41
+ ### Note on the 1.2.0 tag
42
+
43
+ The `v1.2.0` tag had been moved to a commit later than the one published as
44
+ `activeagent 1.2.0`, so the tag and the gem disagreed. It has been repointed
45
+ to the commit that actually produced the release. If you fetched the tag
46
+ between 2026-08-14 and 2026-08-18, re-fetch with `git fetch --tags --force`.
47
+
48
+ ## [1.2.2] - 2026-08-14
49
+
50
+ ### Fixed
51
+
52
+ - **`actionagent`: every engine constant resolves under a host's
53
+ inflections.** 1.2.1 scoped its autoloader override to the basename `api`,
54
+ which covered the controllers under `app/controllers/action_agent/api` and
55
+ nothing else. Seven files camelize differently once a host registers an
56
+ acronym — `mcp_catalog.rb`, `mcp_recording_middleware.rb`,
57
+ `playwright_mcp_client.rb`, `api_key.rb`, and the `api_keys`, `mcp` and
58
+ `mcp_servers` controllers — and each raised `Zeitwerk::NameError` on first
59
+ reference. In a host declaring `inflect.acronym "MCP"` the **Tools view was
60
+ unreachable** (`uninitialized constant
61
+ ActionAgent::ToolDiscovery::McpCatalog`), as were the MCP endpoints and
62
+ anything touching an API key.
63
+
64
+ Every path under the engine now camelizes with Zeitwerk's default
65
+ inflector, ignoring the host's acronyms, scoped by path so the host's own
66
+ constants keep their spelling. The router half generalizes with it: an
67
+ all-caps run in a missing constant is retried in the relaxed spelling
68
+ (`API` → `Api`, `MCPServersController` → `McpServersController`) rather
69
+ than aliasing each pair by hand.
70
+
71
+ ## [1.2.1] - 2026-08-14
72
+
73
+ ### Fixed
74
+
75
+ - **`actionagent`: the install migrations now run on MySQL.** Both templates
76
+ already chose the JSON column type per adapter, but kept `default: []` /
77
+ `default: {}` for every adapter, and MySQL rejects a default on a JSON
78
+ column outright — so `rails g action_agent:install && rails db:migrate`
79
+ aborted mid-`create_table` on any MySQL host. The default (and the paired
80
+ `null: false`, which without it would reject the inserts the default
81
+ existed to satisfy) is now PostgreSQL-only. Every JSON column is read
82
+ through `Array(...)` / `|| {}`, so a NULL reads as the empty value.
83
+ - **`actionagent`: the mount works in a host that declares
84
+ `inflect.acronym "API"`.** An engine's files are autoloaded under the
85
+ host's inflections, so such a host made Zeitwerk expect
86
+ `ActionAgent::API::TracesController` from a file defining
87
+ `ActionAgent::Api::TracesController`, and every request to the mount
88
+ raised `Zeitwerk::NameError`. Rails separately camelizes a route's stored
89
+ controller path with the host's global inflections, which no engine-level
90
+ setting scopes. The autoloader is now pinned to `Api` for this engine's
91
+ own path, and the namespace answers to `API` as well.
92
+
93
+ ## [1.2.0] - 2026-08-14
9
94
 
10
95
  ### ⚠️ The dashboard has moved to its own gem
11
96
 
@@ -106,6 +191,45 @@ observability surface (see `docs/framework/self-hosted-observability.md`):
106
191
  it remains a no-op accessor with its historical default so existing
107
192
  initializers keep booting, and will be removed in the next major.
108
193
 
194
+ ### Agent-as-tool delegation
195
+
196
+ Sub-agents are now a first-class primitive. A tool is a Ruby method your
197
+ model can call; a delegation is another agent your model can call — with
198
+ its own instructions, templates, model and budget.
199
+
200
+ - **`delegation :action, description:`** declares what a sub-agent exposes:
201
+ a description for the calling model, a JSON Schema for its inputs (block
202
+ DSL, a plain hash, or any class responding to `to_json_schema`), and
203
+ optionally a `returns` schema. A declared `returns` becomes the
204
+ sub-agent's `response_format`, and its answer is parsed and checked
205
+ before the caller sees it.
206
+ - **`delegate_to AgentClass`** exposes those contracts to the calling model
207
+ as tools, with `only:`/`except:`/`as:` for scoping and renaming,
208
+ `params:` for forwarding, and `action:` for declaring a contract at the
209
+ call site when you don't own the sub-agent. Per-action scoping via the
210
+ `delegations:` prompt option.
211
+ - **Cost and latency budgets**: `max_calls`, `max_tokens`, `max_cost`,
212
+ `max_duration` and a per-call `timeout`, set per delegation and/or
213
+ agent-wide with `delegation_budget`. Exhausting one returns a structured
214
+ result the model can act on (`on_exceeded: :stop`, the default) instead
215
+ of raising mid-conversation; `:raise` is available. Budgets are scoped
216
+ to a single generation, and spend is readable afterwards via
217
+ `delegation_ledger`.
218
+ - **Swappable backends**: `backend: :ollama` or
219
+ `backend: { provider: :anthropic, model: "claude-haiku-4-5" }` moves a
220
+ delegation to different silicon without touching the sub-agent. Provider
221
+ swaps rebuild provider configuration rather than merging over it, and
222
+ template lookup still resolves to the original agent's views.
223
+ - **Cost registry**: `ActiveAgent::Delegation::Pricing.register` records
224
+ token rates in USD per 1M tokens (no built-in price list, so `max_cost`
225
+ never fires on stale numbers); rates can also be stated inline on a budget.
226
+ - **Instrumentation**: `delegate.active_agent` (agent, sub-agent, action,
227
+ model, duration, usage, cost, ledger) and
228
+ `delegation_refused.active_agent` (violated limit).
229
+ - **New docs** (`docs/actions/delegation.md`) with a worked support-triage
230
+ example, plus test coverage in `test/features/delegation_test.rb` and
231
+ `test/docs/actions/delegation_examples_test.rb`.
232
+
109
233
  ### Dashboard & Telemetry — dev console readiness
110
234
 
111
235
  The dashboard engine — Active Agent's local dev console — now works out of
data/README.md CHANGED
@@ -10,6 +10,16 @@
10
10
  >
11
11
  > *Makes code [TonsOfFun](https://tonsoffun.github.io)!*
12
12
 
13
+ [![Gem Version](https://img.shields.io/gem/v/activeagent?label=activeagent&logo=rubygems&color=CC342D)](https://rubygems.org/gems/activeagent)
14
+ [![actionagent](https://img.shields.io/gem/v/actionagent?label=actionagent&logo=rubygems&color=CC342D)](https://rubygems.org/gems/actionagent)
15
+ [![Downloads](https://img.shields.io/gem/dt/activeagent?label=downloads)](https://rubygems.org/gems/activeagent)
16
+ [![CI](https://github.com/activeagents/activeagent/actions/workflows/ci.yml/badge.svg)](https://github.com/activeagents/activeagent/actions/workflows/ci.yml)
17
+ [![Cross-repo integration](https://github.com/activeagents/activeagent/actions/workflows/integration.yml/badge.svg)](https://github.com/activeagents/activeagent/actions/workflows/integration.yml)
18
+ [![Docs](https://img.shields.io/badge/docs-docs.activeagents.ai-2563eb)](https://docs.activeagents.ai)
19
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D)](https://www.ruby-lang.org)
20
+ [![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-D30001)](https://rubyonrails.org)
21
+ [![License](https://img.shields.io/github/license/activeagents/activeagent)](LICENSE)
22
+
13
23
  # Active Agent
14
24
  Active Agent provides that missing AI layer in the Rails framework, offering a structured approach to building AI-powered applications through Agent Oriented Programming. **Now Agents are Controllers!** Designing applications using agents allows developers to create modular, reusable components that can be easily integrated into existing systems. This approach promotes code reusability, maintainability, and scalability, making it easier to build complex AI-driven applications with the Object Oriented Ruby code you already use today.
15
25
 
@@ -171,6 +181,7 @@ a free low-volume trial.
171
181
  - **View Templates**: Use ERB templates for prompts (text, JSON, HTML)
172
182
  - **Streaming Support**: Real-time response streaming with ActionCable
173
183
  - **Tool/Function Calling**: Agents can use tools to interact with external services
184
+ - **Agent-as-Tool Delegation**: Hand work to sub-agents with declared schemas, cost/latency budgets, and swappable backends
174
185
  - **Context Management**: Maintain conversation history across interactions
175
186
  - **Structured Output**: Define JSON schemas for predictable responses
176
187
 
@@ -205,6 +216,29 @@ response = prompt.generate_now
205
216
  # Response includes tool call results
206
217
  ```
207
218
 
219
+ ### Delegation
220
+ Hand part of a job to a sub-agent, under a declared contract and a budget:
221
+
222
+ ```ruby
223
+ class SummarizerAgent < ApplicationAgent
224
+ generate_with :openai, model: "gpt-4o-mini"
225
+
226
+ delegation :summarize, description: "Condense a document into key points" do
227
+ string :text, required: true, description: "Full document text"
228
+ end
229
+
230
+ def summarize(text:) = prompt(message: text)
231
+ end
232
+
233
+ class ResearchAgent < ApplicationAgent
234
+ generate_with :openai, model: "gpt-4o"
235
+
236
+ delegate_to SummarizerAgent, budget: { max_calls: 3, timeout: 20 }
237
+
238
+ def research(topic:) = prompt(message: "Research #{topic}")
239
+ end
240
+ ```
241
+
208
242
  ## Learn More
209
243
 
210
244
  - [Documentation](https://docs.activeagents.ai)
@@ -5,6 +5,7 @@ require "active_support/core_ext/module/anonymous"
5
5
  require "active_support/core_ext/string/inflections"
6
6
 
7
7
  require "active_agent/concerns/callbacks"
8
+ require "active_agent/concerns/delegation"
8
9
  require "active_agent/concerns/observers"
9
10
  require "active_agent/concerns/parameterized"
10
11
  require "active_agent/concerns/preview"
@@ -42,6 +43,7 @@ module ActiveAgent
42
43
  include AbstractController::Caching
43
44
 
44
45
  include Callbacks
46
+ include Delegation
45
47
  include Parameterized
46
48
  include Provider
47
49
  include Queueing
@@ -299,18 +301,25 @@ module ActiveAgent
299
301
 
300
302
  # @api private
301
303
  def prepare_prompt_parameters
302
- parameters = prompt_options.deep_dup.except(:locals, *PROTECTED_OPTIONS)
304
+ parameters = prompt_options.deep_dup.except(:locals, :delegations, *PROTECTED_OPTIONS)
303
305
 
304
306
  # Render out proc/lamda attributes before rendering templates
305
307
  parameters.deep_transform_values! { _1.respond_to?(:call) ? _1.call : _1 }
306
308
 
309
+ # Expose declared sub-agents (agent-as-tool) alongside the action's own tools
310
+ parameters = apply_delegated_tools(parameters, prompt_options)
311
+
307
312
  # Strip parameters the target model rejects (e.g. temperature/top_p
308
313
  # on thinking-first models) before they reach the provider.
309
314
  ModelCapabilities.sanitize!(parameters)
310
315
 
311
316
  # Apply Callbacks
317
+ #
318
+ # The trace id is written back so it stays stable for the generation and
319
+ # readable from prompt_options — delegated sub-agents inherit it, which
320
+ # is what lets a delegation tree show up as one trace.
312
321
  parameters.merge!(
313
- trace_id: prompt_options[:trace_id] || SecureRandom.uuid,
322
+ trace_id: prompt_options[:trace_id] ||= SecureRandom.uuid,
314
323
  exception_handler:,
315
324
  stream_broadcaster:,
316
325
  tools_function:,
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/delegation/schema"
4
+ require "active_agent/delegation/pricing"
5
+ require "active_agent/delegation/budget"
6
+ require "active_agent/delegation/ledger"
7
+ require "active_agent/delegation/backend"
8
+ require "active_agent/delegation/contract"
9
+ require "active_agent/delegation/definition"
10
+ require "active_agent/delegation/runner"
11
+
12
+ module ActiveAgent
13
+ # Agent-as-tool delegation: hand part of a job to another agent.
14
+ #
15
+ # A tool is a Ruby method the model can call. A *delegation* is another agent
16
+ # the model can call — same mechanism, but the callee has its own
17
+ # instructions, its own templates, its own model, and its own budget. That
18
+ # separation is what makes the pattern worth having as a primitive: a
19
+ # specialist agent stays specialist, and the generalist orchestrating it
20
+ # never inherits its prompt.
21
+ #
22
+ # Delegation has three moving parts, each declared where it belongs:
23
+ #
24
+ # * **The contract** lives on the sub-agent, next to the action it describes.
25
+ # Callers say which agent they want; they never restate its parameters.
26
+ # * **The budget** lives at the call site, because only the caller knows what
27
+ # the work is worth. Exceeding it returns a structured result the model can
28
+ # reason about, not an exception that ends the conversation.
29
+ # * **The backend** lives at the call site too, so the same sub-agent can run
30
+ # on a cheap local model in one parent and a frontier model in another
31
+ # without either agent's code changing.
32
+ #
33
+ # @example Declaring a sub-agent's contract
34
+ # class SummarizerAgent < ApplicationAgent
35
+ # generate_with :openai, model: "gpt-4o-mini"
36
+ #
37
+ # delegation :summarize, description: "Condense a document into key points" do
38
+ # string :text, required: true, description: "Full document text"
39
+ # integer :limit, description: "Maximum number of key points to return"
40
+ #
41
+ # returns do
42
+ # string :summary, required: true, description: "One-paragraph summary"
43
+ # array :points, of: :string, required: true, description: "The key points"
44
+ # end
45
+ # end
46
+ #
47
+ # def summarize(text:, limit: 5)
48
+ # prompt(message: text, limit: limit)
49
+ # end
50
+ # end
51
+ #
52
+ # @example Delegating to it
53
+ # class ResearchAgent < ApplicationAgent
54
+ # generate_with :openai, model: "gpt-4o"
55
+ #
56
+ # delegation_budget max_calls: 8, max_duration: 60
57
+ #
58
+ # delegate_to SummarizerAgent, budget: { max_calls: 3, timeout: 20 }
59
+ # delegate_to FactCheckAgent, as: :verify, backend: { provider: :anthropic, model: "claude-haiku-4-5" }
60
+ #
61
+ # def research(topic:)
62
+ # prompt(message: "Research #{topic}. Summarize sources before citing them.")
63
+ # end
64
+ # end
65
+ #
66
+ # @see ActiveAgent::Delegation::Contract
67
+ # @see ActiveAgent::Delegation::Budget
68
+ # @see ActiveAgent::Delegation::Backend
69
+ module Delegation
70
+ extend ActiveSupport::Concern
71
+
72
+ # Raised when a delegation budget is exhausted and the policy is +:raise+.
73
+ class BudgetExceededError < StandardError
74
+ # @return [Budget::Violation]
75
+ attr_reader :violation
76
+ # @return [Definition]
77
+ attr_reader :definition
78
+
79
+ def initialize(violation, definition:)
80
+ @violation = violation
81
+ @definition = definition
82
+
83
+ super("#{definition.agent_class}##{definition.action} exceeded its delegation budget " \
84
+ "(#{violation.limit}: #{violation.used} of #{violation.allowed} used)")
85
+ end
86
+ end
87
+
88
+ # Raised when a delegated call exceeds its timeout and the policy is +:raise+.
89
+ class TimeoutError < StandardError; end
90
+
91
+ # Raised when a sub-agent's output does not satisfy its declared +returns+
92
+ # schema and the policy is +:raise+.
93
+ class InvalidResultError < StandardError; end
94
+
95
+ included do
96
+ # Contracts this agent exposes to callers, keyed by action.
97
+ class_attribute :delegation_contracts, instance_accessor: false, default: {}.freeze
98
+
99
+ # Sub-agents this agent delegates to, keyed by tool name.
100
+ class_attribute :delegations, instance_accessor: false, default: {}.freeze
101
+
102
+ # Budget covering *all* delegated work in one generation.
103
+ class_attribute :_delegation_budget, instance_accessor: false, default: Budget.new
104
+ end
105
+
106
+ class_methods do
107
+ # Declares what this agent exposes to callers: one action, its inputs,
108
+ # and optionally the shape of what it returns.
109
+ #
110
+ # The description is the only thing a calling model reads before deciding
111
+ # whether to hand work over — write it for someone who has never seen the
112
+ # code.
113
+ #
114
+ # @param action [Symbol, String] the action a caller invokes
115
+ # @param description [String] what the action does
116
+ # @param schema [Hash, Class, Delegation::Schema, nil] inputs, when not using the block DSL
117
+ # @param returns [Hash, Class, Delegation::Schema, nil] declared output shape
118
+ # @param budget [Hash, Delegation::Budget, nil] default budget callers inherit
119
+ # @param on_invalid [Symbol] +:error+ (default) or +:raise+ when output misses required keys
120
+ # @yield input DSL; call +returns+ inside it to declare the output shape
121
+ # @return [Delegation::Contract]
122
+ #
123
+ # @example
124
+ # delegation :translate, description: "Translate text into a target language" do
125
+ # string :text, required: true, description: "Text to translate"
126
+ # string :locale, required: true, description: "BCP 47 target locale, e.g. pt-BR"
127
+ # end
128
+ def delegation(action, description: nil, schema: nil, returns: nil, budget: nil, on_invalid: :error, &block)
129
+ contract = Delegation::Contract.new(
130
+ action: action, description: description, schema: schema,
131
+ returns: returns, budget: budget, on_invalid: on_invalid, &block
132
+ )
133
+
134
+ self.delegation_contracts = delegation_contracts.merge(contract.action => contract).freeze
135
+
136
+ contract
137
+ end
138
+
139
+ # Exposes another agent to this agent's model as a tool.
140
+ #
141
+ # With no options, every contract the sub-agent declares becomes a tool.
142
+ # Narrow that with +only:+/+except:+, rename with +as:+, or declare a
143
+ # contract inline with +action:+ when you don't own the sub-agent.
144
+ #
145
+ # @param agent_class [Class] the sub-agent
146
+ # @param only [Symbol, Array<Symbol>, nil] expose just these actions
147
+ # @param except [Symbol, Array<Symbol>, nil] expose everything but these actions
148
+ # @param as [Symbol, String, nil] tool name (defaults to the action name)
149
+ # @param action [Symbol, String, nil] declare a contract inline for this action
150
+ # @param description [String, nil] overrides the contract's description
151
+ # @param schema [Hash, Class, Delegation::Schema, nil] inline contract inputs
152
+ # @param returns [Hash, Class, Delegation::Schema, nil] inline contract outputs
153
+ # @param backend [Symbol, Hash, Delegation::Backend, nil] provider/model to run this delegation on
154
+ # @param budget [Hash, Delegation::Budget, nil] limits for this delegation
155
+ # @param params [Hash, Symbol, Proc, nil] params forwarded to the sub-agent
156
+ # @yield inline contract input DSL
157
+ # @return [Array<Delegation::Definition>]
158
+ # @raise [ArgumentError] on an unknown action, a name collision, or a sub-agent with no contracts
159
+ #
160
+ # @example Everything the sub-agent declares
161
+ # delegate_to SummarizerAgent
162
+ #
163
+ # @example One action, renamed, on a different backend, with a budget
164
+ # delegate_to SummarizerAgent, only: :summarize, as: :condense,
165
+ # backend: { model: "gpt-4o-mini" }, budget: { max_calls: 3 }
166
+ #
167
+ # @example An agent you don't own, described from here
168
+ # delegate_to Vendor::ClassifierAgent, action: :classify,
169
+ # description: "Classify a support ticket" do
170
+ # string :body, required: true, description: "Ticket body"
171
+ # end
172
+ def delegate_to(agent_class, only: nil, except: nil, as: nil, action: nil, description: nil,
173
+ schema: nil, returns: nil, backend: nil, budget: nil, params: nil, &block)
174
+ contracts = delegation_contracts_for(agent_class, action:, only:, except:, description:, schema:, returns:, &block)
175
+
176
+ if (as || description) && contracts.size > 1
177
+ raise ArgumentError, "`as:` and `description:` describe a single delegation, but #{agent_class} exposes " \
178
+ "#{contracts.size} (#{contracts.map(&:action).join(", ")}). Narrow it with `only:` first."
179
+ end
180
+
181
+ contracts.map do |contract|
182
+ define_delegation Delegation::Definition.new(
183
+ agent_class: agent_class, contract: contract, tool_name: as,
184
+ description: description, backend: backend, budget: budget, params: params
185
+ )
186
+ end
187
+ end
188
+
189
+ # Budget covering every delegation this agent makes in one generation.
190
+ #
191
+ # Per-delegation budgets still apply; a call has to clear both. Called
192
+ # without arguments it reads the current budget.
193
+ #
194
+ # @param limits [Hash] see {Delegation::Budget}
195
+ # @return [Delegation::Budget]
196
+ #
197
+ # @example
198
+ # delegation_budget max_calls: 10, max_duration: 60, max_cost: 0.25
199
+ def delegation_budget(**limits)
200
+ self._delegation_budget = _delegation_budget.merge(Delegation::Budget.build(limits)) if limits.any?
201
+
202
+ _delegation_budget
203
+ end
204
+
205
+ # Tool definitions for this agent's delegations, in the common tools format.
206
+ #
207
+ # @param filter [nil, true, false, Symbol, Array<Symbol>] which delegations to include
208
+ # @return [Array<Hash>]
209
+ def delegated_tools(filter = nil)
210
+ delegations_for(filter).map(&:to_tool)
211
+ end
212
+
213
+ # @param filter [nil, true, false, Symbol, Array<Symbol>]
214
+ # @return [Array<Delegation::Definition>]
215
+ def delegations_for(filter = nil)
216
+ case filter
217
+ when nil, true then delegations.values
218
+ when false then []
219
+ else
220
+ names = Array(filter).map(&:to_sym)
221
+ unknown = names - delegations.keys
222
+ raise ArgumentError, "Unknown #{"delegation".pluralize(unknown.size)}: #{unknown.join(", ")}. " \
223
+ "This agent delegates to: #{delegations.keys.join(", ").presence || "(none)"}" if unknown.any?
224
+
225
+ delegations.values_at(*names)
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ # Registers a definition and the tool method the provider routes to.
232
+ #
233
+ # @param definition [Delegation::Definition]
234
+ # @return [Delegation::Definition]
235
+ def define_delegation(definition)
236
+ assert_available_tool_name!(definition)
237
+
238
+ self.delegations = delegations.merge(definition.tool_name => definition).freeze
239
+
240
+ tool_name = definition.tool_name
241
+ define_method(tool_name) do |**arguments|
242
+ perform_delegation(tool_name, **arguments)
243
+ end
244
+
245
+ definition
246
+ end
247
+
248
+ # @param definition [Delegation::Definition]
249
+ # @raise [ArgumentError]
250
+ def assert_available_tool_name!(definition)
251
+ name = definition.tool_name
252
+ return if delegations.key?(name) # re-declaring a delegation is a deliberate override
253
+
254
+ if method_defined?(name) || private_method_defined?(name)
255
+ raise ArgumentError, "#{self} already responds to ##{name}, so it cannot also be the tool name for " \
256
+ "#{definition.agent_class}##{definition.action}. Rename it with `as:`."
257
+ end
258
+ end
259
+
260
+ # @return [Array<Delegation::Contract>]
261
+ def delegation_contracts_for(agent_class, action:, only:, except:, description:, schema:, returns:, &block)
262
+ assert_agent_class!(agent_class)
263
+
264
+ if action
265
+ assert_action!(agent_class, action)
266
+
267
+ return [ Delegation::Contract.new(
268
+ action: action, description: description, schema: schema, returns: returns, &block
269
+ ) ]
270
+ end
271
+
272
+ contracts = agent_class.delegation_contracts.values
273
+
274
+ if contracts.empty?
275
+ raise ArgumentError, "#{agent_class} does not declare any delegations. Add " \
276
+ "`delegation :action_name, description: \"...\"` to it, or declare one here with " \
277
+ "`delegate_to #{agent_class}, action: :action_name, description: \"...\"`."
278
+ end
279
+
280
+ contracts = filter_contracts(contracts, only: only, except: except, agent_class: agent_class)
281
+ contracts.each { |contract| assert_action!(agent_class, contract.action) }
282
+ contracts
283
+ end
284
+
285
+ # @return [Array<Delegation::Contract>]
286
+ def filter_contracts(contracts, only:, except:, agent_class:)
287
+ selected = contracts
288
+
289
+ if only
290
+ names = Array(only).map(&:to_sym)
291
+ assert_declared!(agent_class, names, contracts)
292
+ selected = selected.select { |contract| names.include?(contract.action) }
293
+ end
294
+
295
+ if except
296
+ names = Array(except).map(&:to_sym)
297
+ assert_declared!(agent_class, names, contracts)
298
+ selected = selected.reject { |contract| names.include?(contract.action) }
299
+ end
300
+
301
+ raise ArgumentError, "No delegations left on #{agent_class} after applying only:/except:" if selected.empty?
302
+
303
+ selected
304
+ end
305
+
306
+ # @raise [ArgumentError]
307
+ def assert_declared!(agent_class, names, contracts)
308
+ unknown = names - contracts.map(&:action)
309
+ return if unknown.empty?
310
+
311
+ raise ArgumentError, "#{agent_class} does not declare #{"delegation".pluralize(unknown.size)} " \
312
+ "#{unknown.join(", ")}. It declares: #{contracts.map(&:action).join(", ")}"
313
+ end
314
+
315
+ # @raise [ArgumentError]
316
+ def assert_agent_class!(agent_class)
317
+ return if agent_class.is_a?(Class) && agent_class < ActiveAgent::Base
318
+
319
+ raise ArgumentError, "delegate_to expects an ActiveAgent::Base subclass, got #{agent_class.inspect}"
320
+ end
321
+
322
+ # @raise [ArgumentError]
323
+ def assert_action!(agent_class, action)
324
+ return if agent_class.method_defined?(action) || agent_class.private_method_defined?(action)
325
+
326
+ raise ArgumentError, "#{agent_class} does not define ##{action}, so it cannot be delegated to."
327
+ end
328
+ end
329
+
330
+ # Ledger covering every delegation made during this generation.
331
+ #
332
+ # @return [Delegation::Ledger]
333
+ def delegation_ledger
334
+ @_delegation_ledger ||= Delegation::Ledger.new
335
+ end
336
+
337
+ # Ledger for a single delegation during this generation.
338
+ #
339
+ # @param tool_name [Symbol, String]
340
+ # @return [Delegation::Ledger]
341
+ def delegation_ledger_for(tool_name)
342
+ delegation_ledgers[tool_name.to_sym] ||= Delegation::Ledger.new
343
+ end
344
+
345
+ # @return [Hash{Symbol => Delegation::Ledger}] per-delegation ledgers
346
+ def delegation_ledgers
347
+ @_delegation_ledgers ||= {}
348
+ end
349
+
350
+ # Runs a delegated call. Providers reach this through the tool method
351
+ # {ClassMethods#delegate_to} defines.
352
+ #
353
+ # @param tool_name [Symbol, String]
354
+ # @param arguments [Hash]
355
+ # @return [Object]
356
+ def perform_delegation(tool_name, **arguments)
357
+ definition = self.class.delegations[tool_name.to_sym]
358
+ raise ArgumentError, "#{self.class} has no delegation named #{tool_name}" unless definition
359
+
360
+ Delegation::Runner.new(definition, owner: self).call(**arguments)
361
+ end
362
+
363
+ private
364
+
365
+ # Merges declared delegations into the tools the provider is given.
366
+ #
367
+ # Scope them per action with the +delegations:+ prompt option: +false+ for
368
+ # none, or a name/list for a subset.
369
+ #
370
+ # @param parameters [Hash]
371
+ # @param prompt_options [Hash]
372
+ # @return [Hash]
373
+ # @api private
374
+ def apply_delegated_tools(parameters, prompt_options)
375
+ tools = self.class.delegated_tools(prompt_options[:delegations])
376
+ return parameters if tools.empty?
377
+
378
+ declared = Array(parameters[:tools])
379
+ names = declared.filter_map { |tool| (tool[:name] || tool["name"]).to_s if tool.is_a?(Hash) }
380
+
381
+ parameters[:tools] = declared + tools.reject { |tool| names.include?(tool[:name]) }
382
+ parameters
383
+ end
384
+ end
385
+ end