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
|
@@ -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, ¶ms) : owner.instance_exec(¶ms)
|
|
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
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Delegation
|
|
5
|
+
# Declarative JSON Schema for a delegated agent's inputs and outputs.
|
|
6
|
+
#
|
|
7
|
+
# A delegation is only as good as its contract: the calling model needs to
|
|
8
|
+
# know exactly what a sub-agent accepts, and the calling *agent* needs to
|
|
9
|
+
# know what shape comes back. Schema builds both from a small DSL, from a
|
|
10
|
+
# plain JSON Schema hash, or from any class that responds to
|
|
11
|
+
# +to_json_schema+ (see {ActiveAgent::SchemaGenerator}).
|
|
12
|
+
#
|
|
13
|
+
# @example DSL
|
|
14
|
+
# Schema.build do
|
|
15
|
+
# string :text, required: true, description: "Document to summarize"
|
|
16
|
+
# integer :max_points, description: "How many bullets to return"
|
|
17
|
+
# array :tags, of: :string, description: "Topic tags"
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Plain JSON Schema
|
|
21
|
+
# Schema.build(type: "object", properties: { text: { type: "string" } }, required: [ "text" ])
|
|
22
|
+
#
|
|
23
|
+
# @example ActiveModel / ActiveRecord
|
|
24
|
+
# Schema.build(ContactForm) # ContactForm includes ActiveAgent::SchemaGenerator
|
|
25
|
+
class Schema
|
|
26
|
+
# Scalar types that get a one-line DSL helper.
|
|
27
|
+
SCALAR_TYPES = %i[string integer number boolean].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Coerces any supported schema source into a Schema.
|
|
31
|
+
#
|
|
32
|
+
# @param source [Schema, Hash, Class, nil] existing schema, raw JSON Schema,
|
|
33
|
+
# or a class responding to +to_json_schema+
|
|
34
|
+
# @yield DSL block (evaluated against a new Schema, or yielded when arity is 1)
|
|
35
|
+
# @return [Schema]
|
|
36
|
+
# @raise [ArgumentError] when the source cannot be interpreted
|
|
37
|
+
def build(source = nil, &block)
|
|
38
|
+
schema =
|
|
39
|
+
case source
|
|
40
|
+
when Schema then source
|
|
41
|
+
when Hash then from_hash(source)
|
|
42
|
+
when nil then new
|
|
43
|
+
else
|
|
44
|
+
if source.respond_to?(:to_json_schema)
|
|
45
|
+
from_hash(source.to_json_schema)
|
|
46
|
+
else
|
|
47
|
+
raise ArgumentError, "Cannot build a delegation schema from #{source.inspect}. " \
|
|
48
|
+
"Pass a Hash, a class responding to #to_json_schema, or use the block DSL."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if block
|
|
53
|
+
block.arity == 1 ? block.call(schema) : schema.instance_eval(&block)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
schema
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param hash [Hash] JSON Schema object
|
|
60
|
+
# @return [Schema]
|
|
61
|
+
def from_hash(hash)
|
|
62
|
+
hash = hash.deep_symbolize_keys
|
|
63
|
+
|
|
64
|
+
new.tap do |schema|
|
|
65
|
+
(hash[:properties] || {}).each { |name, definition| schema.property(name, definition) }
|
|
66
|
+
schema.required(*Array(hash[:required]))
|
|
67
|
+
schema.additional_properties(hash.fetch(:additionalProperties, hash.fetch(:additional_properties, false)))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize
|
|
73
|
+
@properties = {}
|
|
74
|
+
@required = []
|
|
75
|
+
@additional_properties = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] true when nothing has been declared
|
|
79
|
+
def empty?
|
|
80
|
+
@properties.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Declares a property from an already-built JSON Schema fragment.
|
|
84
|
+
#
|
|
85
|
+
# @param name [Symbol, String]
|
|
86
|
+
# @param definition [Hash] JSON Schema fragment (e.g. +{ type: "string" }+)
|
|
87
|
+
# @return [void]
|
|
88
|
+
def property(name, definition)
|
|
89
|
+
@properties[name.to_sym] = definition.deep_symbolize_keys
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Declares a property.
|
|
93
|
+
#
|
|
94
|
+
# @param name [Symbol, String]
|
|
95
|
+
# @param type [Symbol, String] JSON Schema type
|
|
96
|
+
# @param required [Boolean] whether the calling model must supply it
|
|
97
|
+
# @param description [String, nil] shown to the calling model — write it for a reader who
|
|
98
|
+
# has never seen your code
|
|
99
|
+
# @param options [Hash] any other JSON Schema keyword (+enum+, +format+, +minimum+, ...)
|
|
100
|
+
# @yield nested DSL for object properties
|
|
101
|
+
# @return [void]
|
|
102
|
+
def param(name, type = :string, required: false, description: nil, **options, &block)
|
|
103
|
+
definition = { type: type.to_s, description: description }.compact.merge(options)
|
|
104
|
+
|
|
105
|
+
if block
|
|
106
|
+
nested = self.class.build(&block)
|
|
107
|
+
definition = definition.merge(nested.to_json_schema.except(:type))
|
|
108
|
+
definition[:type] = "object"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
property(name, definition)
|
|
112
|
+
self.required(name) if required
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
SCALAR_TYPES.each do |type|
|
|
116
|
+
define_method(type) do |name, **options|
|
|
117
|
+
param(name, type, **options)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Declares an object property.
|
|
122
|
+
#
|
|
123
|
+
# @param name [Symbol, String]
|
|
124
|
+
# @yield nested DSL
|
|
125
|
+
def object(name, **options, &block)
|
|
126
|
+
param(name, :object, **options, &block)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Declares an array property.
|
|
130
|
+
#
|
|
131
|
+
# @param name [Symbol, String]
|
|
132
|
+
# @param of [Symbol, String, Hash, nil] item type, or a JSON Schema fragment for items
|
|
133
|
+
# @yield nested DSL describing an object item type
|
|
134
|
+
def array(name, of: nil, **options, &block)
|
|
135
|
+
items =
|
|
136
|
+
if block
|
|
137
|
+
self.class.build(&block).to_json_schema
|
|
138
|
+
elsif of.is_a?(Hash)
|
|
139
|
+
of.deep_symbolize_keys
|
|
140
|
+
elsif of
|
|
141
|
+
{ type: of.to_s }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
param(name, :array, **options.merge({ items: items }.compact))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Marks properties as required.
|
|
148
|
+
#
|
|
149
|
+
# @param names [Array<Symbol, String>]
|
|
150
|
+
# @return [Array<Symbol>]
|
|
151
|
+
def required(*names)
|
|
152
|
+
names.flatten.each { |name| @required |= [ name.to_sym ] }
|
|
153
|
+
@required
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param value [Boolean]
|
|
157
|
+
def additional_properties(value = true)
|
|
158
|
+
@additional_properties = value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [Array<Symbol>] declared property names
|
|
162
|
+
def keys
|
|
163
|
+
@properties.keys
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @return [Array<Symbol>] required property names
|
|
167
|
+
def required_keys
|
|
168
|
+
@required.dup
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @return [Hash] JSON Schema object
|
|
172
|
+
#
|
|
173
|
+
# @note Deep-duplicated: providers normalize tool definitions in place,
|
|
174
|
+
# and a schema is shared by every generation that exposes it.
|
|
175
|
+
def to_json_schema
|
|
176
|
+
{
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: @properties.deep_dup,
|
|
179
|
+
required: @required.map(&:to_s),
|
|
180
|
+
additionalProperties: @additional_properties
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
alias_method :to_h, :to_json_schema
|
|
184
|
+
|
|
185
|
+
# JSON Schema for a +response_format+ payload.
|
|
186
|
+
#
|
|
187
|
+
# ActiveAgent camelizes response-format schema *keys* on the way to the
|
|
188
|
+
# provider, but the +required+ array holds string *values* — so they are
|
|
189
|
+
# camelized here to keep the emitted schema internally consistent.
|
|
190
|
+
# {Assistant#parsed_json} underscores the keys again on the way back, so
|
|
191
|
+
# agent code only ever sees snake_case.
|
|
192
|
+
#
|
|
193
|
+
# @param name [String] schema name reported to the provider
|
|
194
|
+
# @param strict [Boolean]
|
|
195
|
+
# @return [Hash]
|
|
196
|
+
def to_response_format(name:, strict: true)
|
|
197
|
+
schema = to_json_schema
|
|
198
|
+
schema[:required] = schema[:required].map { |key| key.camelize(:lower) }
|
|
199
|
+
|
|
200
|
+
{ name: name, schema: schema, strict: strict }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Validates a parsed payload against the declared required properties.
|
|
204
|
+
#
|
|
205
|
+
# This is a deliberately shallow check: it catches the failure that
|
|
206
|
+
# actually happens in practice (a model omitting a required key) without
|
|
207
|
+
# pulling a full JSON Schema validator into the gem's dependencies.
|
|
208
|
+
#
|
|
209
|
+
# @param payload [Hash, nil]
|
|
210
|
+
# @return [Array<Symbol>] missing required keys
|
|
211
|
+
def missing_keys(payload)
|
|
212
|
+
return required_keys if payload.nil?
|
|
213
|
+
return [] unless payload.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
keys = payload.keys.map { |key| key.to_s.underscore.to_sym }
|
|
216
|
+
required_keys.reject { |key| keys.include?(key) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -54,8 +54,11 @@ module ActiveAgent
|
|
|
54
54
|
attr_internal :options, :context, :trace_id, # Setup
|
|
55
55
|
:request, :message_stack, # Runtime
|
|
56
56
|
:stream_broadcaster, :streaming, # Callback (Streams)
|
|
57
|
+
:stream_completion_pending, # Callback (Streams)
|
|
58
|
+
:stream_completion_result, # Callback (Streams)
|
|
57
59
|
:tools_function, # Callback (Tools)
|
|
58
60
|
:usage_stack, # Usage Tracking
|
|
61
|
+
:stream_usage_index, # Usage Tracking (Streams)
|
|
59
62
|
:max_tool_turns, :tool_turns # Tool-loop safety
|
|
60
63
|
|
|
61
64
|
# Upper bound on tool-calling round-trips within one generation. A
|
|
@@ -118,6 +121,9 @@ module ActiveAgent
|
|
|
118
121
|
self.context = kwargs
|
|
119
122
|
self.message_stack = []
|
|
120
123
|
self.usage_stack = []
|
|
124
|
+
self.stream_completion_pending = false
|
|
125
|
+
self.stream_completion_result = nil
|
|
126
|
+
self.stream_usage_index = nil
|
|
121
127
|
end
|
|
122
128
|
|
|
123
129
|
# Generates prompt preview without executing the API call.
|
|
@@ -179,6 +185,9 @@ module ActiveAgent
|
|
|
179
185
|
#
|
|
180
186
|
# @return [ActiveAgent::Providers::Common::PromptResponse]
|
|
181
187
|
def resolve_prompt
|
|
188
|
+
# Each turn streams its own usage; see record_stream_usage.
|
|
189
|
+
self.stream_usage_index = nil
|
|
190
|
+
|
|
182
191
|
api_parameters = api_request_build(prepare_prompt_request, prompt_request_type)
|
|
183
192
|
api_response = instrument("prompt.provider.active_agent") do |payload|
|
|
184
193
|
raw_response = with_exception_handling { api_prompt_execute(api_parameters) }
|
|
@@ -193,6 +202,12 @@ module ActiveAgent
|
|
|
193
202
|
raw_response
|
|
194
203
|
end
|
|
195
204
|
|
|
205
|
+
# A stream that deferred its completion has already run
|
|
206
|
+
# process_prompt_finished (including any tool-call recursion) from
|
|
207
|
+
# stream_finished!, and holds the response it produced. Calling it a
|
|
208
|
+
# second time here would re-run that work.
|
|
209
|
+
return stream_completion_result if stream_completion_result
|
|
210
|
+
|
|
196
211
|
process_prompt_finished(api_response)
|
|
197
212
|
end
|
|
198
213
|
|
|
@@ -231,7 +246,11 @@ module ActiveAgent
|
|
|
231
246
|
# @return [Hash] API request parameters
|
|
232
247
|
def api_request_build(request, request_type)
|
|
233
248
|
parameters = request_type.serialize(request)
|
|
234
|
-
|
|
249
|
+
|
|
250
|
+
if request.try(:stream)
|
|
251
|
+
parameters[:stream] = process_stream
|
|
252
|
+
parameters.deep_merge!(api_stream_usage_parameters)
|
|
253
|
+
end
|
|
235
254
|
|
|
236
255
|
if options.extra_headers.present?
|
|
237
256
|
parameters[:request_options] = { extra_headers: options.extra_headers }.deep_merge(parameters[:request_options] || {})
|
|
@@ -240,6 +259,20 @@ module ActiveAgent
|
|
|
240
259
|
parameters
|
|
241
260
|
end
|
|
242
261
|
|
|
262
|
+
# Extra request parameters needed to make the provider report token usage
|
|
263
|
+
# while streaming.
|
|
264
|
+
#
|
|
265
|
+
# A streaming request returns its usage on a final chunk rather than in a
|
|
266
|
+
# response body, and several providers only send that chunk when the
|
|
267
|
+
# request asks for it. Providers that need such a flag override this;
|
|
268
|
+
# the default asks for nothing, so a provider that reports usage
|
|
269
|
+
# unconditionally — or not at all — is unaffected.
|
|
270
|
+
#
|
|
271
|
+
# @return [Hash]
|
|
272
|
+
def api_stream_usage_parameters
|
|
273
|
+
{}
|
|
274
|
+
end
|
|
275
|
+
|
|
243
276
|
# @return [Proc] for each response chunk
|
|
244
277
|
def process_stream
|
|
245
278
|
proc do |api_response_chunk|
|
|
@@ -258,10 +291,27 @@ module ActiveAgent
|
|
|
258
291
|
api_prompt_executer.create(**parameters)
|
|
259
292
|
else
|
|
260
293
|
api_prompt_executer.stream(**parameters.except(:stream)).each(¶meters[:stream])
|
|
294
|
+
stream_finished!
|
|
261
295
|
nil
|
|
262
296
|
end
|
|
263
297
|
end
|
|
264
298
|
|
|
299
|
+
# Runs the deferred end-of-generation work once a stream has drained.
|
|
300
|
+
#
|
|
301
|
+
# A chunk handler that completed the message marks the generation
|
|
302
|
+
# pending rather than finishing inline, because providers emit their
|
|
303
|
+
# usage chunk after the content is done — finishing inline would build
|
|
304
|
+
# the response before usage is recorded. Handlers that finish inline
|
|
305
|
+
# leave the flag unset and this is a no-op.
|
|
306
|
+
#
|
|
307
|
+
# @return [Object, nil] result of process_prompt_finished, if deferred
|
|
308
|
+
def stream_finished!
|
|
309
|
+
return unless stream_completion_pending
|
|
310
|
+
|
|
311
|
+
self.stream_completion_pending = false
|
|
312
|
+
self.stream_completion_result = process_prompt_finished
|
|
313
|
+
end
|
|
314
|
+
|
|
265
315
|
# Returns provider-specific API executer for prompt requests.
|
|
266
316
|
#
|
|
267
317
|
# Since all currently implemented providers use stainless gems, subclasses
|
|
@@ -305,6 +355,52 @@ module ActiveAgent
|
|
|
305
355
|
fail NotImplementedError, "Subclass expected to implement"
|
|
306
356
|
end
|
|
307
357
|
|
|
358
|
+
# Records token usage carried on a streaming chunk.
|
|
359
|
+
#
|
|
360
|
+
# The non-streaming path pushes onto usage_stack in resolve_prompt, from
|
|
361
|
+
# the response body. A streaming request has no response body — the
|
|
362
|
+
# provider streams chunks and api_prompt_execute returns nil — so usage
|
|
363
|
+
# would otherwise be lost, and every streamed generation reports zero
|
|
364
|
+
# tokens and zero cost. Chunk handlers call this when they see usage so
|
|
365
|
+
# the two paths converge on the same usage_stack.
|
|
366
|
+
#
|
|
367
|
+
# Ignores blank and all-zero payloads: providers send `usage: null` on
|
|
368
|
+
# ordinary content chunks, and pushing those would add empty entries to
|
|
369
|
+
# a stack that is summed with reduce(:+).
|
|
370
|
+
#
|
|
371
|
+
# A streamed usage payload is a running total for the turn, not a delta,
|
|
372
|
+
# so the turn keeps a single entry that later payloads replace. Chat
|
|
373
|
+
# Completions sends exactly one, on a final chunk, but Gemini's
|
|
374
|
+
# OpenAI-compatible endpoint repeats a cumulative usage on every chunk —
|
|
375
|
+
# summing those would report a turn's tokens many times over. Tool
|
|
376
|
+
# calling still accumulates across turns: each turn enters resolve_prompt
|
|
377
|
+
# and starts a fresh entry.
|
|
378
|
+
#
|
|
379
|
+
# @param raw_usage [Hash, Object, nil] provider-shaped usage payload
|
|
380
|
+
# @return [void]
|
|
381
|
+
def record_stream_usage(raw_usage)
|
|
382
|
+
return if raw_usage.blank?
|
|
383
|
+
|
|
384
|
+
# from_provider_usage only reads hashes, and the stainless gems hand
|
|
385
|
+
# back model objects (e.g. OpenAI::Models::CompletionUsage), so an
|
|
386
|
+
# unconverted object is silently dropped.
|
|
387
|
+
raw_usage = raw_usage.deep_to_h if raw_usage.respond_to?(:deep_to_h)
|
|
388
|
+
raw_usage = raw_usage.to_h if !raw_usage.is_a?(Hash) && raw_usage.respond_to?(:to_h)
|
|
389
|
+
|
|
390
|
+
usage = Common::Usage.from_provider_usage(raw_usage)
|
|
391
|
+
return if usage.blank?
|
|
392
|
+
return if usage.total_tokens.to_i.zero? &&
|
|
393
|
+
usage.input_tokens.to_i.zero? &&
|
|
394
|
+
usage.output_tokens.to_i.zero?
|
|
395
|
+
|
|
396
|
+
if stream_usage_index
|
|
397
|
+
usage_stack[stream_usage_index] = usage
|
|
398
|
+
else
|
|
399
|
+
self.stream_usage_index = usage_stack.length
|
|
400
|
+
usage_stack.push(usage)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
308
404
|
# Broadcasts stream open event.
|
|
309
405
|
#
|
|
310
406
|
# Fires once per request cycle, even during multi-turn tool calling.
|