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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +185 -0
  3. data/README.md +58 -14
  4. data/lib/active_agent/base.rb +11 -2
  5. data/lib/active_agent/concerns/delegation.rb +385 -0
  6. data/lib/active_agent/delegation/backend.rb +109 -0
  7. data/lib/active_agent/delegation/budget.rb +138 -0
  8. data/lib/active_agent/delegation/contract.rb +117 -0
  9. data/lib/active_agent/delegation/definition.rb +95 -0
  10. data/lib/active_agent/delegation/ledger.rb +56 -0
  11. data/lib/active_agent/delegation/pricing.rb +106 -0
  12. data/lib/active_agent/delegation/runner.rb +283 -0
  13. data/lib/active_agent/delegation/schema.rb +220 -0
  14. data/lib/active_agent/providers/_base_provider.rb +97 -1
  15. data/lib/active_agent/providers/open_ai/chat_provider.rb +21 -2
  16. data/lib/active_agent/telemetry/configuration.rb +13 -9
  17. data/lib/active_agent/telemetry/instrumentation.rb +38 -1
  18. data/lib/active_agent/telemetry/tool_origin.rb +90 -0
  19. data/lib/active_agent/telemetry.rb +1 -0
  20. data/lib/active_agent/version.rb +1 -1
  21. data/lib/active_agent.rb +1 -5
  22. metadata +31 -32
  23. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +0 -138
  24. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +0 -64
  25. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +0 -129
  26. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +0 -123
  27. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +0 -56
  28. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +0 -14
  29. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +0 -49
  30. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +0 -65
  31. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +0 -86
  32. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +0 -256
  33. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +0 -113
  34. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +0 -208
  35. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +0 -60
  36. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +0 -46
  37. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +0 -125
  38. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +0 -83
  39. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +0 -52
  40. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +0 -169
  41. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +0 -193
  42. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +0 -214
  43. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +0 -117
  44. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +0 -135
  45. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +0 -145
  46. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +0 -36
  47. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +0 -94
  48. data/lib/active_agent/dashboard/config/routes.rb +0 -19
  49. data/lib/active_agent/dashboard/engine.rb +0 -43
  50. data/lib/active_agent/dashboard.rb +0 -161
  51. data/lib/generators/active_agent/dashboard/install_generator.rb +0 -92
  52. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +0 -67
  53. 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