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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module ActiveAgent
6
+ module Delegation
7
+ # What a sub-agent promises: one action, its inputs, and optionally the
8
+ # shape of what it returns.
9
+ #
10
+ # The contract is declared on the sub-agent itself, next to the action it
11
+ # describes, so a delegation stays correct when the action changes. Callers
12
+ # then say only which agent they want — they never restate its parameters.
13
+ #
14
+ # @example
15
+ # class SummarizerAgent < ApplicationAgent
16
+ # delegation :summarize, description: "Condense a document into key points" do
17
+ # string :text, required: true, description: "Full document text"
18
+ # integer :limit, description: "Maximum number of key points"
19
+ #
20
+ # returns do
21
+ # string :summary, required: true, description: "One-paragraph summary"
22
+ # array :points, of: :string, required: true, description: "Key points"
23
+ # end
24
+ # end
25
+ #
26
+ # def summarize(text:, limit: 5)
27
+ # prompt(message: text, limit: limit)
28
+ # end
29
+ # end
30
+ class Contract
31
+ # What to do when a sub-agent's output does not satisfy its +returns+ schema.
32
+ #
33
+ # +:error+ — hand the calling model a structured error so it can retry or
34
+ # route around the failure (default).
35
+ # +:raise+ — raise {InvalidResultError} and abort the generation.
36
+ INVALID_POLICIES = %i[error raise].freeze
37
+
38
+ # @return [Symbol] the sub-agent action this contract describes
39
+ attr_reader :action
40
+ # @return [String] what the action does, written for the calling model
41
+ attr_reader :description
42
+ # @return [Schema] declared inputs
43
+ attr_reader :schema
44
+ # @return [Schema, nil] declared output shape, when the action returns structured data
45
+ attr_reader :returns
46
+ # @return [Budget] default budget suggested by the sub-agent
47
+ attr_reader :budget
48
+ # @return [Symbol] :error or :raise
49
+ attr_reader :on_invalid
50
+
51
+ # @param action [Symbol, String]
52
+ # @param description [String]
53
+ # @param schema [Schema, Hash, Class, nil] inputs, or nil to use the block DSL
54
+ # @param returns [Schema, Hash, Class, nil] declared output shape
55
+ # @param budget [Budget, Hash, nil] default budget for callers
56
+ # @param on_invalid [Symbol] :error or :raise
57
+ # @yield input DSL; may also call +returns+ to declare the output shape
58
+ def initialize(action:, description:, schema: nil, returns: nil, budget: nil, on_invalid: :error, &block)
59
+ @action = action.to_sym
60
+ @description = description
61
+ @returns = returns && Schema.build(returns)
62
+ @budget = Budget.build(budget)
63
+ @on_invalid = on_invalid.to_sym
64
+
65
+ unless INVALID_POLICIES.include?(@on_invalid)
66
+ raise ArgumentError, "Unknown delegation on_invalid policy #{@on_invalid.inspect}. " \
67
+ "Valid policies: #{INVALID_POLICIES.join(", ")}"
68
+ end
69
+
70
+ raise ArgumentError, "A delegation needs a description — it is the only thing the calling model reads" if @description.blank?
71
+
72
+ @schema = Schema.build(schema)
73
+
74
+ if block
75
+ dsl = DSL.new(self, @schema)
76
+ block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
77
+ end
78
+ end
79
+
80
+ # @return [Boolean]
81
+ def structured?
82
+ returns.present?
83
+ end
84
+
85
+ # Sets the declared output shape. Used by the DSL's +returns+ helper.
86
+ #
87
+ # @param schema [Schema]
88
+ # @return [Schema]
89
+ # @api private
90
+ def returns=(schema)
91
+ @returns = schema
92
+ end
93
+
94
+ # Wraps the input schema DSL so that +returns+ inside a delegation block
95
+ # declares the *output* shape rather than an input property.
96
+ #
97
+ # @api private
98
+ class DSL < SimpleDelegator
99
+ # @param contract [Contract]
100
+ # @param schema [Schema]
101
+ def initialize(contract, schema)
102
+ @contract = contract
103
+ super(schema)
104
+ end
105
+
106
+ # Declares the shape the action returns.
107
+ #
108
+ # @param source [Schema, Hash, Class, nil]
109
+ # @yield output DSL
110
+ # @return [Schema]
111
+ def returns(source = nil, &block)
112
+ @contract.returns = Schema.build(source, &block)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Delegation
5
+ # A sub-agent bound into a calling agent as a tool.
6
+ #
7
+ # Pairs the sub-agent's {Contract} — what it accepts and returns — with the
8
+ # decisions that belong to the caller: what to call it, what it may spend,
9
+ # and which backend serves it.
10
+ class Definition
11
+ # @return [Class] the sub-agent class
12
+ attr_reader :agent_class
13
+ # @return [Contract]
14
+ attr_reader :contract
15
+ # @return [Symbol] the tool name exposed to the calling model
16
+ attr_reader :tool_name
17
+ # @return [String]
18
+ attr_reader :description
19
+ # @return [Backend]
20
+ attr_reader :backend
21
+ # @return [Budget]
22
+ attr_reader :budget
23
+ # @return [Hash, Symbol, Proc, nil] params forwarded to the sub-agent
24
+ attr_reader :params
25
+
26
+ # @param agent_class [Class]
27
+ # @param contract [Contract]
28
+ # @param tool_name [Symbol, String, nil] defaults to the contract's action
29
+ # @param description [String, nil] overrides the contract's description
30
+ # @param backend [Backend, Symbol, Hash, nil]
31
+ # @param budget [Budget, Hash, nil] merged over the contract's default budget
32
+ # @param params [Hash, Symbol, Proc, nil]
33
+ def initialize(agent_class:, contract:, tool_name: nil, description: nil, backend: nil, budget: nil, params: nil)
34
+ @agent_class = agent_class
35
+ @contract = contract
36
+ @tool_name = (tool_name || contract.action).to_sym
37
+ @description = description.presence || contract.description
38
+ @backend = Backend.build(backend)
39
+ @budget = contract.budget.merge(Budget.build(budget))
40
+ @params = params
41
+ end
42
+
43
+ # @return [Symbol] the sub-agent action invoked
44
+ def action = contract.action
45
+
46
+ # @return [Schema] declared inputs
47
+ def schema = contract.schema
48
+
49
+ # @return [Schema, nil] declared outputs
50
+ def returns = contract.returns
51
+
52
+ # @return [Boolean]
53
+ def structured? = contract.structured?
54
+
55
+ # @return [Symbol]
56
+ def on_invalid = contract.on_invalid
57
+
58
+ # The class a call actually instantiates, after any backend swap.
59
+ #
60
+ # @return [Class]
61
+ def resolved_agent_class
62
+ backend.agent_class_for(agent_class)
63
+ end
64
+
65
+ # Tool definition in ActiveAgent's common tools format.
66
+ #
67
+ # This is what the calling model sees — the whole point of declaring a
68
+ # schema rather than describing the sub-agent in prose.
69
+ #
70
+ # @return [Hash]
71
+ def to_tool
72
+ {
73
+ name: tool_name.to_s,
74
+ description: description,
75
+ parameters: schema.to_json_schema
76
+ }
77
+ end
78
+
79
+ # Returns a copy with call-site overrides applied.
80
+ #
81
+ # @return [Definition]
82
+ def with(tool_name: nil, description: nil, backend: nil, budget: nil, params: nil)
83
+ self.class.new(
84
+ agent_class: agent_class,
85
+ contract: contract,
86
+ tool_name: tool_name || self.tool_name,
87
+ description: description || self.description,
88
+ backend: backend || self.backend,
89
+ budget: budget ? self.budget.merge(Budget.build(budget)) : self.budget,
90
+ params: params || self.params
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Delegation
5
+ # Running tally of what delegated work has consumed.
6
+ #
7
+ # One ledger is kept per delegation plus one for the agent as a whole, and
8
+ # both live on the agent instance — which is created fresh for every
9
+ # generation. A budget is therefore scoped to a single generation and its
10
+ # entire tool loop, with no cross-request bleed and nothing to reset.
11
+ #
12
+ # @example Inspecting spend after a generation
13
+ # agent = ResearchAgent.new
14
+ # agent.process(:research, topic: "hydrogen storage")
15
+ # agent.process_prompt
16
+ # agent.delegation_ledger.to_h
17
+ # #=> { calls: 2, tokens: 1_840, cost: 0.0004, duration: 3.1 }
18
+ class Ledger
19
+ # @return [Integer] delegated calls completed
20
+ attr_reader :calls
21
+ # @return [Integer] cumulative total tokens
22
+ attr_reader :tokens
23
+ # @return [Float] cumulative USD spend (0.0 when no rates are known)
24
+ attr_reader :cost
25
+ # @return [Float] cumulative wall-clock seconds
26
+ attr_reader :duration
27
+
28
+ def initialize
29
+ @calls = 0
30
+ @tokens = 0
31
+ @cost = 0.0
32
+ @duration = 0.0
33
+ end
34
+
35
+ # Records one delegated call.
36
+ #
37
+ # @param tokens [Integer, nil]
38
+ # @param cost [Float, nil] nil when the model's rates are unknown
39
+ # @param duration [Float] seconds
40
+ # @return [self]
41
+ def record(tokens: 0, cost: nil, duration: 0.0)
42
+ @calls += 1
43
+ @tokens += tokens.to_i
44
+ @cost += cost.to_f
45
+ @duration += duration.to_f
46
+
47
+ self
48
+ end
49
+
50
+ # @return [Hash]
51
+ def to_h
52
+ { calls: calls, tokens: tokens, cost: cost, duration: duration }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Delegation
5
+ # Token price registry used to turn usage into dollars for cost budgets.
6
+ #
7
+ # ActiveAgent deliberately ships no built-in price list: vendor pricing
8
+ # changes far faster than a gem release, and a stale table silently
9
+ # under-reports spend. Register the rates your app actually pays — once,
10
+ # in an initializer — and every cost budget in the app uses them.
11
+ #
12
+ # Rates are expressed in **USD per one million tokens**, matching how
13
+ # every major provider publishes them.
14
+ #
15
+ # @example Register rates for the models you use
16
+ # # config/initializers/active_agent.rb
17
+ # ActiveAgent::Delegation::Pricing.register("gpt-4o-mini", input: 0.15, output: 0.60)
18
+ # ActiveAgent::Delegation::Pricing.register(/\Aclaude-haiku/, input: 1.00, output: 5.00)
19
+ #
20
+ # @example Or state rates inline on a single budget
21
+ # delegate_to SummarizerAgent, budget: { max_cost: 0.05, rates: { input: 0.15, output: 0.60 } }
22
+ module Pricing
23
+ # A registered rate card.
24
+ Rate = Struct.new(:pattern, :input, :output, keyword_init: true) do
25
+ # @param model [String]
26
+ # @return [Boolean]
27
+ def matches?(model)
28
+ case pattern
29
+ when Regexp then pattern.match?(model)
30
+ else model.to_s.start_with?(pattern.to_s)
31
+ end
32
+ end
33
+ end
34
+
35
+ class << self
36
+ # @return [Array<Rate>] registered rates, most recently registered first
37
+ def rates
38
+ @rates ||= []
39
+ end
40
+
41
+ # Registers a rate card.
42
+ #
43
+ # String patterns match by prefix (so +"gpt-4o-mini"+ covers
44
+ # +"gpt-4o-mini-2024-07-18"+); Regexp patterns match as written. Later
45
+ # registrations win over earlier ones.
46
+ #
47
+ # @param pattern [String, Regexp] matched against the model name
48
+ # @param input [Float] USD per 1M input tokens
49
+ # @param output [Float] USD per 1M output tokens
50
+ # @return [Rate]
51
+ def register(pattern, input:, output:)
52
+ Rate.new(pattern: pattern, input: input.to_f, output: output.to_f).tap do |rate|
53
+ rates.unshift(rate)
54
+ end
55
+ end
56
+
57
+ # Clears the registry. Mostly useful in tests.
58
+ #
59
+ # @return [void]
60
+ def reset!
61
+ @rates = []
62
+ end
63
+
64
+ # @param model [String, nil]
65
+ # @return [Hash, nil] +{ input:, output: }+ in USD per 1M tokens
66
+ def rates_for(model)
67
+ return nil if model.blank?
68
+
69
+ rate = rates.find { |candidate| candidate.matches?(model) }
70
+ { input: rate.input, output: rate.output } if rate
71
+ end
72
+
73
+ # Computes the dollar cost of a single generation.
74
+ #
75
+ # @param usage [ActiveAgent::Providers::Common::Usage, nil]
76
+ # @param model [String, nil] used to look up registered rates
77
+ # @param rates [Hash, nil] inline +{ input:, output: }+ overriding the registry
78
+ # @return [Float, nil] nil when no rates are known for the model
79
+ def cost_for(usage:, model: nil, rates: nil)
80
+ return nil if usage.nil?
81
+
82
+ resolved = normalize(rates) || rates_for(model)
83
+ return nil if resolved.nil?
84
+
85
+ input = (usage.input_tokens || 0) * resolved[:input]
86
+ output = (usage.output_tokens || 0) * resolved[:output]
87
+
88
+ (input + output) / 1_000_000.0
89
+ end
90
+
91
+ private
92
+
93
+ # @param rates [Hash, nil]
94
+ # @return [Hash, nil]
95
+ def normalize(rates)
96
+ return nil if rates.blank?
97
+
98
+ rates = rates.symbolize_keys
99
+ return nil unless rates[:input] || rates[:output]
100
+
101
+ { input: rates[:input].to_f, output: rates[:output].to_f }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module ActiveAgent
6
+ module Delegation
7
+ # Executes one delegated call: budget check, sub-agent generation, result
8
+ # validation, accounting.
9
+ #
10
+ # The runner is what makes a sub-agent a *tool* rather than a method call.
11
+ # It answers to the calling model in the model's own terms — a refused call
12
+ # comes back as a structured result the model can reason about, not as an
13
+ # exception that ends the conversation.
14
+ #
15
+ # @api private
16
+ class Runner
17
+ # @return [Definition]
18
+ attr_reader :definition
19
+ # @return [ActiveAgent::Base] the delegating agent instance
20
+ attr_reader :owner
21
+
22
+ # @param definition [Definition]
23
+ # @param owner [ActiveAgent::Base]
24
+ def initialize(definition, owner:)
25
+ @definition = definition
26
+ @owner = owner
27
+ end
28
+
29
+ # Runs the delegated call.
30
+ #
31
+ # @param arguments [Hash] arguments supplied by the calling model
32
+ # @return [Object] the sub-agent's result, or a structured error the
33
+ # calling model can act on
34
+ # @raise [BudgetExceededError] when the budget policy is +:raise+
35
+ # @raise [InvalidResultError] when the returns policy is +:raise+
36
+ def call(**arguments)
37
+ if (violation = budget_violation)
38
+ return refuse(violation)
39
+ end
40
+
41
+ arguments = coerce_arguments(arguments)
42
+
43
+ instrument(arguments) do |payload|
44
+ started = clock
45
+ response = nil
46
+
47
+ begin
48
+ response = with_timeout { generate(arguments) }
49
+ rescue Timeout::Error
50
+ duration = clock - started
51
+ record(duration: duration)
52
+ payload[:status] = :timed_out
53
+ payload[:duration_ms] = (duration * 1_000).round
54
+
55
+ next timed_out(duration)
56
+ end
57
+
58
+ duration = clock - started
59
+ usage = response.usage
60
+ model = response.model.presence || generation_model(response)
61
+ cost = Pricing.cost_for(usage: usage, model: model, rates: budget.rates)
62
+
63
+ record(tokens: usage&.total_tokens, cost: cost, duration: duration)
64
+
65
+ payload[:status] = :ok
66
+ payload[:model] = model
67
+ payload[:duration_ms] = (duration * 1_000).round
68
+ payload[:usage] = usage
69
+ payload[:cost] = cost
70
+ payload[:ledger] = ledgers.last.to_h
71
+
72
+ result(response, payload)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # @return [Budget]
79
+ def budget = definition.budget
80
+
81
+ # Agent-wide ledger first, then this delegation's own — a call has to
82
+ # clear both.
83
+ #
84
+ # @return [Array<Ledger>]
85
+ def ledgers
86
+ @ledgers ||= [ owner.delegation_ledger, owner.delegation_ledger_for(definition.tool_name) ]
87
+ end
88
+
89
+ # @return [Budget::Violation, nil]
90
+ def budget_violation
91
+ owner.class.delegation_budget.violation_for(ledgers.first) || budget.violation_for(ledgers.last)
92
+ end
93
+
94
+ # @param tokens [Integer, nil]
95
+ # @param cost [Float, nil]
96
+ # @param duration [Float]
97
+ # @return [void]
98
+ def record(tokens: 0, cost: nil, duration: 0.0)
99
+ ledgers.each { |ledger| ledger.record(tokens: tokens, cost: cost, duration: duration) }
100
+ end
101
+
102
+ # Drops arguments the contract never declared, so a model that
103
+ # hallucinates an extra key gets a working call instead of an
104
+ # +ArgumentError+ from the sub-agent's method signature.
105
+ #
106
+ # @param arguments [Hash]
107
+ # @return [Hash]
108
+ def coerce_arguments(arguments)
109
+ arguments = arguments.symbolize_keys
110
+ return arguments if definition.schema.empty?
111
+
112
+ arguments.slice(*definition.schema.keys)
113
+ end
114
+
115
+ # @param arguments [Hash]
116
+ # @return [ActiveAgent::Providers::Common::PromptResponse]
117
+ def generate(arguments)
118
+ agent = definition.resolved_agent_class.new
119
+ agent.params = resolved_params(arguments)
120
+ agent.process(definition.action, **arguments)
121
+
122
+ definition.backend.apply(agent)
123
+ apply_returns_format(agent)
124
+ inherit_trace_id(agent)
125
+
126
+ agent.process_prompt
127
+ end
128
+
129
+ # A delegated generation is part of its parent's work, so it carries the
130
+ # parent's trace id — otherwise the sub-agent's tokens and latency land
131
+ # in a separate trace and the budget you set has nothing to show for it.
132
+ #
133
+ # @param agent [ActiveAgent::Base]
134
+ # @return [void]
135
+ def inherit_trace_id(agent)
136
+ trace_id = owner.prompt_options[:trace_id]
137
+ agent.prompt_options[:trace_id] ||= trace_id if trace_id
138
+ end
139
+
140
+ # A declared +returns+ schema *is* the response format for the delegated
141
+ # call; the sub-agent does not have to restate it.
142
+ #
143
+ # @param agent [ActiveAgent::Base]
144
+ # @return [void]
145
+ def apply_returns_format(agent)
146
+ return unless definition.structured?
147
+
148
+ agent.prompt_options[:response_format] = {
149
+ type: :json_schema,
150
+ json_schema: definition.returns.to_response_format(name: "#{definition.tool_name}_result")
151
+ }
152
+ end
153
+
154
+ # @param arguments [Hash]
155
+ # @return [Hash]
156
+ def resolved_params(arguments)
157
+ case (params = definition.params)
158
+ when nil then {}
159
+ when Hash then params
160
+ when Symbol then owner.send(params)
161
+ when Proc then params.arity == 1 ? owner.instance_exec(arguments, &params) : owner.instance_exec(&params)
162
+ else
163
+ raise ArgumentError, "Delegation params must be a Hash, Symbol or Proc, got #{params.inspect}"
164
+ end.to_h.symbolize_keys
165
+ end
166
+
167
+ # @yield the delegated generation
168
+ # @return [Object]
169
+ def with_timeout
170
+ return yield unless budget.timeout
171
+
172
+ Timeout.timeout(budget.timeout) { yield }
173
+ end
174
+
175
+ # @param response [ActiveAgent::Providers::Common::PromptResponse]
176
+ # @param payload [Hash] instrumentation payload
177
+ # @return [Object]
178
+ def result(response, payload)
179
+ message = response.message
180
+ return nil if message.nil?
181
+
182
+ return text_of(message) unless definition.structured?
183
+
184
+ parsed = message.respond_to?(:parsed_json) ? message.parsed_json : nil
185
+ missing = definition.returns.missing_keys(parsed)
186
+ return parsed if missing.empty?
187
+
188
+ payload[:status] = :invalid_result
189
+ payload[:missing] = missing
190
+
191
+ invalid(missing, text_of(message))
192
+ end
193
+
194
+ # @param message [Object]
195
+ # @return [String]
196
+ def text_of(message)
197
+ message.respond_to?(:text) ? message.text : message.content.to_s
198
+ end
199
+
200
+ # @param response [ActiveAgent::Providers::Common::PromptResponse]
201
+ # @return [String, nil]
202
+ def generation_model(response)
203
+ response.context.is_a?(Hash) ? response.context[:model] : nil
204
+ end
205
+
206
+ # @param violation [Budget::Violation]
207
+ # @return [Hash]
208
+ def refuse(violation)
209
+ ActiveSupport::Notifications.instrument("delegation_refused.active_agent", instrument_payload.merge(
210
+ status: :budget_exceeded, limit: violation.limit, allowed: violation.allowed, used: violation.used
211
+ ))
212
+
213
+ if budget.policy == :raise
214
+ raise BudgetExceededError.new(violation, definition: definition)
215
+ end
216
+
217
+ { error: "budget_exceeded", limit: violation.limit.to_s, allowed: violation.allowed, used: violation.used, message: violation.message }
218
+ end
219
+
220
+ # @param duration [Float]
221
+ # @return [Hash]
222
+ def timed_out(duration)
223
+ if budget.policy == :raise
224
+ raise TimeoutError, "#{definition.agent_class}##{definition.action} exceeded its #{budget.timeout}s delegation timeout"
225
+ end
226
+
227
+ {
228
+ error: "timeout",
229
+ limit: "timeout",
230
+ allowed: budget.timeout,
231
+ used: duration.round(3),
232
+ message: "The #{definition.tool_name} delegation timed out after #{budget.timeout}s. " \
233
+ "Answer with the information you already have, or call it with a smaller request."
234
+ }
235
+ end
236
+
237
+ # @param missing [Array<Symbol>]
238
+ # @param content [String]
239
+ # @return [Hash]
240
+ def invalid(missing, content)
241
+ if definition.on_invalid == :raise
242
+ raise InvalidResultError, "#{definition.agent_class}##{definition.action} returned a result missing " \
243
+ "required #{"key".pluralize(missing.size)}: #{missing.join(", ")}"
244
+ end
245
+
246
+ {
247
+ error: "invalid_result",
248
+ missing: missing.map(&:to_s),
249
+ message: "The #{definition.tool_name} delegation returned a result missing required " \
250
+ "#{"key".pluralize(missing.size)}: #{missing.join(", ")}.",
251
+ content: content
252
+ }
253
+ end
254
+
255
+ # @param arguments [Hash]
256
+ # @yield [Hash] instrumentation payload
257
+ def instrument(arguments, &block)
258
+ ActiveSupport::Notifications.instrument(
259
+ "delegate.active_agent", instrument_payload.merge(arguments: arguments), &block
260
+ )
261
+ end
262
+
263
+ # @return [Hash]
264
+ def instrument_payload
265
+ {
266
+ agent: owner.class.name,
267
+ delegate: definition.agent_class.name,
268
+ action: definition.action,
269
+ tool: definition.tool_name.to_s,
270
+ provider: definition.backend.provider,
271
+ budget: budget.to_h.except(:rates)
272
+ }.compact
273
+ end
274
+
275
+ # Monotonic so a clock adjustment mid-generation can't corrupt a latency budget.
276
+ #
277
+ # @return [Float]
278
+ def clock
279
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
280
+ end
281
+ end
282
+ end
283
+ end