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 +4 -4
- data/CHANGELOG.md +125 -1
- data/README.md +34 -0
- data/lib/active_agent/base.rb +11 -2
- data/lib/active_agent/concerns/delegation.rb +385 -0
- data/lib/active_agent/delegation/backend.rb +109 -0
- data/lib/active_agent/delegation/budget.rb +138 -0
- data/lib/active_agent/delegation/contract.rb +117 -0
- data/lib/active_agent/delegation/definition.rb +95 -0
- data/lib/active_agent/delegation/ledger.rb +56 -0
- data/lib/active_agent/delegation/pricing.rb +106 -0
- data/lib/active_agent/delegation/runner.rb +283 -0
- data/lib/active_agent/delegation/schema.rb +220 -0
- data/lib/active_agent/providers/_base_provider.rb +97 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +21 -2
- data/lib/active_agent/telemetry/instrumentation.rb +34 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45044ca9e6ddabcdfb1be0f32074149a3e9fb8fcaa9e7c8a79c02f81c73eb64b
|
|
4
|
+
data.tar.gz: 7f0a2489a68e01f0dd0e9a1fb96878bb900acaab173ba0a0157ad82a8a510fe6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
+
[](https://rubygems.org/gems/activeagent)
|
|
14
|
+
[](https://rubygems.org/gems/actionagent)
|
|
15
|
+
[](https://rubygems.org/gems/activeagent)
|
|
16
|
+
[](https://github.com/activeagents/activeagent/actions/workflows/ci.yml)
|
|
17
|
+
[](https://github.com/activeagents/activeagent/actions/workflows/integration.yml)
|
|
18
|
+
[](https://docs.activeagents.ai)
|
|
19
|
+
[](https://www.ruby-lang.org)
|
|
20
|
+
[](https://rubyonrails.org)
|
|
21
|
+
[](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)
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -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]
|
|
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
|