activeagent 1.1.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 +185 -0
- data/README.md +58 -14
- 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/configuration.rb +13 -9
- data/lib/active_agent/telemetry/instrumentation.rb +38 -1
- data/lib/active_agent/telemetry/tool_origin.rb +90 -0
- data/lib/active_agent/telemetry.rb +1 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -5
- metadata +31 -32
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +0 -138
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +0 -64
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +0 -129
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +0 -123
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +0 -56
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +0 -14
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +0 -49
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +0 -65
- data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +0 -86
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +0 -256
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +0 -113
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +0 -208
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +0 -60
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +0 -46
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +0 -125
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +0 -83
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +0 -52
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +0 -169
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +0 -193
- data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +0 -214
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +0 -117
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +0 -135
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +0 -145
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +0 -36
- data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +0 -94
- data/lib/active_agent/dashboard/config/routes.rb +0 -19
- data/lib/active_agent/dashboard/engine.rb +0 -43
- data/lib/active_agent/dashboard.rb +0 -161
- data/lib/generators/active_agent/dashboard/install_generator.rb +0 -92
- data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +0 -67
- data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +0 -46
|
@@ -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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# The provider and options a delegated call runs against.
|
|
6
|
+
#
|
|
7
|
+
# A sub-agent's contract — what it accepts, what it returns — is separate
|
|
8
|
+
# from what actually serves it. Backend is that seam: the same
|
|
9
|
+
# +SummarizerAgent+ can run on a cheap local model inside one parent and on
|
|
10
|
+
# a frontier model inside another, and neither parent's code changes when
|
|
11
|
+
# you move it.
|
|
12
|
+
#
|
|
13
|
+
# @example Swap the model, keep the provider
|
|
14
|
+
# delegate_to SummarizerAgent, backend: { model: "gpt-4o-mini", temperature: 0 }
|
|
15
|
+
#
|
|
16
|
+
# @example Swap the provider entirely
|
|
17
|
+
# delegate_to SummarizerAgent, backend: :ollama
|
|
18
|
+
#
|
|
19
|
+
# @example Swap both
|
|
20
|
+
# delegate_to SummarizerAgent, backend: { provider: :anthropic, model: "claude-haiku-4-5" }
|
|
21
|
+
class Backend
|
|
22
|
+
# @return [Symbol, nil] provider reference (+:openai+, +:anthropic+, ...)
|
|
23
|
+
attr_reader :provider
|
|
24
|
+
# @return [Hash] prompt options applied at the call site
|
|
25
|
+
attr_reader :options
|
|
26
|
+
|
|
27
|
+
# @param spec [Backend, Symbol, String, Hash, nil]
|
|
28
|
+
# @return [Backend]
|
|
29
|
+
def self.build(spec = nil)
|
|
30
|
+
case spec
|
|
31
|
+
when Backend then spec
|
|
32
|
+
when nil then new
|
|
33
|
+
when Symbol, String then new(provider: spec)
|
|
34
|
+
when Hash then new(**spec.symbolize_keys)
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "Delegation backend must be a Symbol, Hash or #{name}, got #{spec.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param provider [Symbol, String, nil]
|
|
41
|
+
# @param options [Hash] prompt options (model, temperature, ...)
|
|
42
|
+
def initialize(provider: nil, **options)
|
|
43
|
+
@provider = provider&.to_sym
|
|
44
|
+
@options = options
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether this backend changes anything
|
|
49
|
+
def overrides?
|
|
50
|
+
provider.present? || options.any?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolves the class a delegated call should instantiate.
|
|
54
|
+
#
|
|
55
|
+
# Swapping providers means rebuilding provider configuration (host, keys,
|
|
56
|
+
# service), not just merging a hash — so the swap goes through a cached
|
|
57
|
+
# subclass configured by +generate_with+, the same code path a
|
|
58
|
+
# hand-written agent takes. The subclass reports its parent's name so
|
|
59
|
+
# template lookup keeps resolving to the original agent's views.
|
|
60
|
+
#
|
|
61
|
+
# @param agent_class [Class] the declared sub-agent class
|
|
62
|
+
# @return [Class]
|
|
63
|
+
def agent_class_for(agent_class)
|
|
64
|
+
return agent_class if provider.blank?
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@agent_classes ||= {}
|
|
68
|
+
@agent_classes[agent_class] ||= build_agent_class(agent_class)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Applies call-site options to a prepared agent instance.
|
|
73
|
+
#
|
|
74
|
+
# Applied after the action has run so the delegation site wins over the
|
|
75
|
+
# sub-agent's own +prompt+ options — a call-site override is runtime
|
|
76
|
+
# configuration, which outranks class configuration everywhere else in
|
|
77
|
+
# ActiveAgent.
|
|
78
|
+
#
|
|
79
|
+
# @param agent [ActiveAgent::Base]
|
|
80
|
+
# @return [ActiveAgent::Base]
|
|
81
|
+
def apply(agent)
|
|
82
|
+
agent.prompt_options.merge!(options) if options.any?
|
|
83
|
+
agent
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash]
|
|
87
|
+
def to_h
|
|
88
|
+
{ provider: provider }.compact.merge(options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# @param agent_class [Class]
|
|
94
|
+
# @return [Class]
|
|
95
|
+
def build_agent_class(agent_class)
|
|
96
|
+
backend_provider = provider
|
|
97
|
+
inherited_name = agent_class.name
|
|
98
|
+
|
|
99
|
+
Class.new(agent_class) do
|
|
100
|
+
# Keep the parent's identity so `agent_name`, and therefore view
|
|
101
|
+
# lookup, resolves to the original agent's templates.
|
|
102
|
+
define_singleton_method(:name) { inherited_name }
|
|
103
|
+
|
|
104
|
+
generate_with backend_provider
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# Cost and latency limits for delegated work.
|
|
6
|
+
#
|
|
7
|
+
# A sub-agent is a loop inside a loop: the parent model decides how often
|
|
8
|
+
# to call it, and each call spends tokens and wall-clock time nobody
|
|
9
|
+
# explicitly authorized. A budget puts a ceiling on that — per delegation
|
|
10
|
+
# and across the agent as a whole — so a runaway hand-off degrades into a
|
|
11
|
+
# bounded answer instead of an unbounded bill.
|
|
12
|
+
#
|
|
13
|
+
# Every limit is optional; an empty budget imposes nothing.
|
|
14
|
+
#
|
|
15
|
+
# @example Per delegation
|
|
16
|
+
# delegate_to SummarizerAgent, budget: { max_calls: 3, max_tokens: 8_000, timeout: 20 }
|
|
17
|
+
#
|
|
18
|
+
# @example Across every delegation this agent makes
|
|
19
|
+
# delegation_budget max_calls: 10, max_duration: 60, max_cost: 0.25,
|
|
20
|
+
# rates: { input: 0.15, output: 0.60 }
|
|
21
|
+
class Budget
|
|
22
|
+
# What to do when a limit is reached.
|
|
23
|
+
#
|
|
24
|
+
# +:stop+ — return a structured "budget exhausted" result to the calling
|
|
25
|
+
# model so it can finish with what it already has (default).
|
|
26
|
+
# +:raise+ — raise {BudgetExceededError} and abort the generation.
|
|
27
|
+
POLICIES = %i[stop raise].freeze
|
|
28
|
+
|
|
29
|
+
LIMITS = %i[max_calls max_tokens max_cost max_duration].freeze
|
|
30
|
+
KEYS = (LIMITS + %i[timeout rates on_exceeded]).freeze
|
|
31
|
+
|
|
32
|
+
# A limit that has been reached.
|
|
33
|
+
Violation = Struct.new(:limit, :allowed, :used, keyword_init: true) do
|
|
34
|
+
# @return [String] wording aimed at the calling model, not at a developer
|
|
35
|
+
def message
|
|
36
|
+
"Delegation budget exhausted (#{limit}: #{format_number(used)} of #{format_number(allowed)} used). " \
|
|
37
|
+
"Do not retry this tool; answer with the information you already have."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def format_number(value)
|
|
43
|
+
value.is_a?(Float) ? value.round(6) : value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Integer, nil] maximum number of delegated calls
|
|
48
|
+
attr_reader :max_calls
|
|
49
|
+
# @return [Integer, nil] maximum cumulative tokens across delegated calls
|
|
50
|
+
attr_reader :max_tokens
|
|
51
|
+
# @return [Float, nil] maximum cumulative spend in USD
|
|
52
|
+
attr_reader :max_cost
|
|
53
|
+
# @return [Float, nil] maximum cumulative wall-clock seconds
|
|
54
|
+
attr_reader :max_duration
|
|
55
|
+
# @return [Float, nil] per-call wall-clock timeout in seconds
|
|
56
|
+
attr_reader :timeout
|
|
57
|
+
# @return [Hash, nil] inline token rates in USD per 1M tokens
|
|
58
|
+
attr_reader :rates
|
|
59
|
+
# @return [Symbol, nil] :stop or :raise
|
|
60
|
+
attr_reader :on_exceeded
|
|
61
|
+
|
|
62
|
+
# Coerces a budget spec into a Budget.
|
|
63
|
+
#
|
|
64
|
+
# @param spec [Budget, Hash, nil]
|
|
65
|
+
# @return [Budget]
|
|
66
|
+
# @raise [ArgumentError] on unknown keys or an invalid policy
|
|
67
|
+
def self.build(spec = nil)
|
|
68
|
+
case spec
|
|
69
|
+
when Budget then spec
|
|
70
|
+
when nil then new
|
|
71
|
+
when Hash then new(**spec.symbolize_keys)
|
|
72
|
+
else
|
|
73
|
+
raise ArgumentError, "Delegation budget must be a Hash or #{name}, got #{spec.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(**options)
|
|
78
|
+
unknown = options.keys - KEYS
|
|
79
|
+
raise ArgumentError, "Unknown delegation budget keys: #{unknown.join(", ")}. Valid keys: #{KEYS.join(", ")}" if unknown.any?
|
|
80
|
+
|
|
81
|
+
@max_calls = options[:max_calls]
|
|
82
|
+
@max_tokens = options[:max_tokens]
|
|
83
|
+
@max_cost = options[:max_cost]
|
|
84
|
+
@max_duration = options[:max_duration]
|
|
85
|
+
@timeout = options[:timeout]
|
|
86
|
+
@rates = options[:rates]
|
|
87
|
+
@on_exceeded = options[:on_exceeded]&.to_sym
|
|
88
|
+
|
|
89
|
+
if @on_exceeded && !POLICIES.include?(@on_exceeded)
|
|
90
|
+
raise ArgumentError, "Unknown delegation budget policy #{@on_exceeded.inspect}. Valid policies: #{POLICIES.join(", ")}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns a budget where +other+'s settings win over this one's.
|
|
95
|
+
#
|
|
96
|
+
# @param other [Budget, nil]
|
|
97
|
+
# @return [Budget]
|
|
98
|
+
def merge(other)
|
|
99
|
+
return self if other.nil?
|
|
100
|
+
|
|
101
|
+
self.class.new(**to_h.merge(other.to_h))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] whether any limit is set
|
|
105
|
+
def limited?
|
|
106
|
+
LIMITS.any? { |limit| public_send(limit) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Symbol] the effective policy
|
|
110
|
+
def policy
|
|
111
|
+
on_exceeded || :stop
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Finds the first limit the ledger has already reached.
|
|
115
|
+
#
|
|
116
|
+
# Limits are checked *before* a call runs, because token spend can only
|
|
117
|
+
# be measured after the fact. A budget of +max_tokens: 8_000+ therefore
|
|
118
|
+
# means "stop delegating once 8,000 tokens have been spent", not "never
|
|
119
|
+
# exceed 8,000 tokens".
|
|
120
|
+
#
|
|
121
|
+
# @param ledger [Ledger]
|
|
122
|
+
# @return [Violation, nil]
|
|
123
|
+
def violation_for(ledger)
|
|
124
|
+
return Violation.new(limit: :max_calls, allowed: max_calls, used: ledger.calls) if max_calls && ledger.calls >= max_calls
|
|
125
|
+
return Violation.new(limit: :max_tokens, allowed: max_tokens, used: ledger.tokens) if max_tokens && ledger.tokens >= max_tokens
|
|
126
|
+
return Violation.new(limit: :max_cost, allowed: max_cost, used: ledger.cost) if max_cost && ledger.cost >= max_cost
|
|
127
|
+
return Violation.new(limit: :max_duration, allowed: max_duration, used: ledger.duration) if max_duration && ledger.duration >= max_duration
|
|
128
|
+
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Hash] only the settings that were actually provided
|
|
133
|
+
def to_h
|
|
134
|
+
KEYS.index_with { |key| public_send(key) }.compact
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|