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,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.
|
|
@@ -26,6 +26,15 @@ module ActiveAgent
|
|
|
26
26
|
|
|
27
27
|
protected
|
|
28
28
|
|
|
29
|
+
# Chat Completions reports a streamed request's token usage on a final
|
|
30
|
+
# chunk, and only when the request opts in.
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
# @see Base#api_stream_usage_parameters
|
|
34
|
+
def api_stream_usage_parameters
|
|
35
|
+
{ stream_options: { include_usage: true } }
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
# @return [OpenAI::Client::Completions] the API client for chat completions
|
|
30
39
|
# @see Base#api_prompt_executer
|
|
31
40
|
def api_prompt_executer
|
|
@@ -100,7 +109,12 @@ module ActiveAgent
|
|
|
100
109
|
# Called Multiple Times: [Chunk<T>, T]<Content, ToolsCall>
|
|
101
110
|
case api_response_event.type
|
|
102
111
|
when :chunk
|
|
112
|
+
# The usage chunk carries usage and an empty choices array, so it
|
|
113
|
+
# must be read before choices.first is dereferenced below.
|
|
114
|
+
record_stream_usage(api_response_event.chunk.try(:usage))
|
|
115
|
+
|
|
103
116
|
api_message = api_response_event.chunk.choices.first
|
|
117
|
+
return if api_message.nil?
|
|
104
118
|
|
|
105
119
|
# If we have a delta, we need to update a message in the stack
|
|
106
120
|
message = find_or_create_message(api_message.index)
|
|
@@ -118,8 +132,13 @@ module ActiveAgent
|
|
|
118
132
|
# Returns the full content when complete
|
|
119
133
|
# => {type: :"content.done", content: "Hi there! How can I help you today?", parsed: nil}
|
|
120
134
|
|
|
121
|
-
#
|
|
122
|
-
|
|
135
|
+
# Close out and run tooling callbacks (Recursive) — but not here.
|
|
136
|
+
# The usage chunk is emitted *after* content.done, and finishing
|
|
137
|
+
# at this point builds the response from a usage_stack that has
|
|
138
|
+
# not received it yet, so a streamed generation reports zero
|
|
139
|
+
# tokens. Deferred to stream_finished!, which the base provider
|
|
140
|
+
# calls once the stream has drained.
|
|
141
|
+
self.stream_completion_pending = true
|
|
123
142
|
when :"tool_calls.function.arguments.delta"
|
|
124
143
|
# => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
|
|
125
144
|
when :"tool_calls.function.arguments.done"
|
|
@@ -78,15 +78,19 @@ module ActiveAgent
|
|
|
78
78
|
private
|
|
79
79
|
|
|
80
80
|
# Persists a trace through the dashboard's trace model, honoring
|
|
81
|
-
#
|
|
82
|
-
#
|
|
81
|
+
# ActionAgent.trace_model_class overrides. Idempotent on trace_id, as
|
|
82
|
+
# HTTP ingest is.
|
|
83
|
+
#
|
|
84
|
+
# The dashboard is the `actionagent` gem, which the framework does not
|
|
85
|
+
# depend on — every reference to it here is guarded, so local_storage
|
|
86
|
+
# simply reports that it has nowhere to write when it isn't installed.
|
|
83
87
|
def dashboard_store
|
|
84
88
|
@dashboard_store ||= lambda do |trace, sdk|
|
|
85
89
|
model = local_trace_model
|
|
86
90
|
unless model
|
|
87
91
|
resolved_logger.error(
|
|
88
92
|
"[ActiveAgent::Telemetry] local_storage is enabled but no trace model is available — " \
|
|
89
|
-
"run `rails generate
|
|
93
|
+
"add the actionagent gem and run `rails generate action_agent:install` first"
|
|
90
94
|
)
|
|
91
95
|
next
|
|
92
96
|
end
|
|
@@ -98,10 +102,10 @@ module ActiveAgent
|
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
def local_trace_model
|
|
101
|
-
if defined?(
|
|
102
|
-
|
|
103
|
-
elsif defined?(
|
|
104
|
-
|
|
105
|
+
if defined?(::ActionAgent) && ::ActionAgent.respond_to?(:trace_model)
|
|
106
|
+
::ActionAgent.trace_model
|
|
107
|
+
elsif defined?(::ActionAgent::TelemetryTrace)
|
|
108
|
+
::ActionAgent::TelemetryTrace
|
|
105
109
|
end
|
|
106
110
|
rescue NameError
|
|
107
111
|
nil
|
|
@@ -124,10 +128,10 @@ module ActiveAgent
|
|
|
124
128
|
# isn't mounted there. Separate from #dashboard_mount_path so it can be
|
|
125
129
|
# exercised against a route set directly.
|
|
126
130
|
public def mount_path_in(route_set)
|
|
127
|
-
return nil unless defined?(::
|
|
131
|
+
return nil unless defined?(::ActionAgent::Engine)
|
|
128
132
|
|
|
129
133
|
route = route_set.routes.find do |candidate|
|
|
130
|
-
mounted_engine(candidate.app) == ::
|
|
134
|
+
mounted_engine(candidate.app) == ::ActionAgent::Engine
|
|
131
135
|
end
|
|
132
136
|
return nil unless route
|
|
133
137
|
|
|
@@ -94,12 +94,24 @@ module ActiveAgent
|
|
|
94
94
|
prompt_span.set_attribute("prompt.input.tools", JSON.generate(roster)) if roster.any?
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
# prompt_options[:messages] holds the turns a caller passed
|
|
98
|
+
# explicitly. An agent that renders its user turn from the
|
|
99
|
+
# action's template — the idiomatic form, `instructions:` plus
|
|
100
|
+
# `locals:` — has none at this point: the rendering happens
|
|
101
|
+
# later, in prepare_prompt_parameters. Falling back to it means
|
|
102
|
+
# the message the model actually received is on the trace either
|
|
103
|
+
# way, which is what an evaluation scores.
|
|
104
|
+
outbound = prompt_options[:messages]
|
|
105
|
+
outbound = rendered_prompt_messages if outbound.blank?
|
|
106
|
+
|
|
107
|
+
if outbound.present?
|
|
98
108
|
serialized = Array(outbound).map { |message|
|
|
99
109
|
if message.is_a?(Hash)
|
|
100
110
|
role = message[:role] || message["role"] || "user"
|
|
101
111
|
content = message[:content] || message["content"]
|
|
102
112
|
{ role: role.to_s, content: telemetry_truncate(content) }
|
|
113
|
+
elsif message.respond_to?(:content)
|
|
114
|
+
{ role: (message.try(:role) || "user").to_s, content: telemetry_truncate(message.content) }
|
|
103
115
|
else
|
|
104
116
|
{ role: "user", content: telemetry_truncate(message) }
|
|
105
117
|
end
|
|
@@ -147,6 +159,7 @@ module ActiveAgent
|
|
|
147
159
|
tool_span = span.add_span("tool.#{tool_call[:name]}", span_type: :tool)
|
|
148
160
|
tool_span.set_attribute("tool.name", tool_call[:name])
|
|
149
161
|
tool_span.set_attribute("tool.id", tool_call[:id]) if tool_call[:id]
|
|
162
|
+
ToolOrigin.annotate(tool_span, tool_call[:name])
|
|
150
163
|
tool_span.finish
|
|
151
164
|
end
|
|
152
165
|
end
|
|
@@ -202,6 +215,9 @@ module ActiveAgent
|
|
|
202
215
|
|
|
203
216
|
tool_span = parent.add_span("tool.#{tool_name}", span_type: :tool)
|
|
204
217
|
tool_span.set_attribute("tool.name", tool_name.to_s)
|
|
218
|
+
# Records which MCP server (if any) serves this tool, so tool
|
|
219
|
+
# traffic can be grouped by service downstream.
|
|
220
|
+
ToolOrigin.annotate(tool_span, tool_name)
|
|
205
221
|
arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
|
|
206
222
|
if arguments.present?
|
|
207
223
|
tool_span.set_attribute("tool.input.args", agent.send(:telemetry_truncate, JSON.generate(arguments)))
|
|
@@ -253,6 +269,27 @@ module ActiveAgent
|
|
|
253
269
|
# tool loop) can't bloat the trace payload.
|
|
254
270
|
TELEMETRY_ATTRIBUTE_MAX_CHARS = 4_000
|
|
255
271
|
|
|
272
|
+
# The turns this generation will actually send, for an agent that
|
|
273
|
+
# renders its user message from the action's template rather than
|
|
274
|
+
# passing `messages:`. prepare_prompt_parameters is a pure function of
|
|
275
|
+
# prompt_options — it deep_dups its input and mutates no instance
|
|
276
|
+
# state — so calling it here is a read, not a side effect. It does
|
|
277
|
+
# re-render the templates, which is why it is only reached when there
|
|
278
|
+
# are no explicit messages to record.
|
|
279
|
+
#
|
|
280
|
+
# Never raises: a provider that builds parameters differently, or an
|
|
281
|
+
# agent whose templates need context this call does not have, must
|
|
282
|
+
# cost the generation nothing more than an absent attribute.
|
|
283
|
+
def rendered_prompt_messages
|
|
284
|
+
return unless respond_to?(:prepare_prompt_parameters, true)
|
|
285
|
+
|
|
286
|
+
parameters = prepare_prompt_parameters
|
|
287
|
+
parameters[:messages] || parameters["messages"]
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
logger&.debug { "[ActiveAgent::Telemetry] could not read rendered messages: #{e.class}: #{e.message}" }
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
256
293
|
def telemetry_truncate(value)
|
|
257
294
|
text = value.to_s
|
|
258
295
|
return text if text.length <= TELEMETRY_ATTRIBUTE_MAX_CHARS
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Classifies where a tool call came from, so dashboards can group tool
|
|
6
|
+
# traffic by the service that serves it instead of showing a flat list
|
|
7
|
+
# of names.
|
|
8
|
+
#
|
|
9
|
+
# Telemetry only ever sees the name a provider used to invoke the tool,
|
|
10
|
+
# so the origin has to be recovered from naming conventions. The MCP
|
|
11
|
+
# ecosystem settled on a namespaced form — +mcp__<server>__<tool>+ —
|
|
12
|
+
# which most clients (Claude Code, Cursor, the Ruby MCP clients) emit
|
|
13
|
+
# verbatim, and that is the strongest signal available. Everything else
|
|
14
|
+
# falls back to "the agent class defines this method".
|
|
15
|
+
#
|
|
16
|
+
# Classification happens at instrumentation time rather than at read
|
|
17
|
+
# time so the attribution is recorded in the span itself: a trace stays
|
|
18
|
+
# self-describing, and consumers (the gem dashboard, activeagents.ai,
|
|
19
|
+
# any OTLP exporter) all read the same fields instead of each
|
|
20
|
+
# re-implementing the guess.
|
|
21
|
+
#
|
|
22
|
+
# @example Namespaced MCP tool
|
|
23
|
+
# ToolOrigin.classify("mcp__playwright__browser_navigate")
|
|
24
|
+
# # => { origin: "mcp", server: "playwright", tool: "browser_navigate" }
|
|
25
|
+
#
|
|
26
|
+
# @example Agent-defined method
|
|
27
|
+
# ToolOrigin.classify("lookup_order")
|
|
28
|
+
# # => { origin: "agent", server: nil, tool: "lookup_order" }
|
|
29
|
+
module ToolOrigin
|
|
30
|
+
# +mcp__<server>__<tool>+. The tool half may itself contain "__", so
|
|
31
|
+
# only the first two segments are structural.
|
|
32
|
+
MCP_PATTERN = /\Amcp__([^_]+(?:_[^_]+)*?)__(.+)\z/
|
|
33
|
+
|
|
34
|
+
# Origin values written to +tool.origin+.
|
|
35
|
+
MCP = "mcp"
|
|
36
|
+
AGENT = "agent"
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Classifies a tool name.
|
|
41
|
+
#
|
|
42
|
+
# @param name [String, Symbol] the tool name as the provider invoked it
|
|
43
|
+
# @return [Hash] +:origin+ ("mcp" or "agent"), +:server+ (MCP server
|
|
44
|
+
# name or nil), and +:tool+ (the bare tool name with any namespace
|
|
45
|
+
# stripped)
|
|
46
|
+
def classify(name)
|
|
47
|
+
name = name.to_s
|
|
48
|
+
|
|
49
|
+
if (match = MCP_PATTERN.match(name))
|
|
50
|
+
{ origin: MCP, server: match[1], tool: match[2] }
|
|
51
|
+
else
|
|
52
|
+
{ origin: AGENT, server: nil, tool: name }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether a tool name is namespaced to an MCP server.
|
|
57
|
+
#
|
|
58
|
+
# @param name [String, Symbol]
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def mcp?(name)
|
|
61
|
+
MCP_PATTERN.match?(name.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The MCP server a tool belongs to, if any.
|
|
65
|
+
#
|
|
66
|
+
# @param name [String, Symbol]
|
|
67
|
+
# @return [String, nil]
|
|
68
|
+
def server_for(name)
|
|
69
|
+
classify(name)[:server]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Writes the classification onto a span. Only sets +tool.mcp_server+
|
|
73
|
+
# when there is a server to name, so agent-defined tools don't carry
|
|
74
|
+
# an empty attribute.
|
|
75
|
+
#
|
|
76
|
+
# @param span [ActiveAgent::Telemetry::Span]
|
|
77
|
+
# @param name [String, Symbol] the tool name
|
|
78
|
+
# @return [Hash] the classification, for callers that also want it
|
|
79
|
+
def annotate(span, name)
|
|
80
|
+
classification = classify(name)
|
|
81
|
+
span.set_attribute("tool.origin", classification[:origin])
|
|
82
|
+
if classification[:server]
|
|
83
|
+
span.set_attribute("tool.mcp_server", classification[:server])
|
|
84
|
+
span.set_attribute("tool.base_name", classification[:tool])
|
|
85
|
+
end
|
|
86
|
+
classification
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/active_agent/version.rb
CHANGED
data/lib/active_agent.rb
CHANGED
|
@@ -16,10 +16,6 @@ require "active_support/lazy_load_hooks"
|
|
|
16
16
|
# Module Level Extensions
|
|
17
17
|
require "active_agent/configuration"
|
|
18
18
|
require "active_agent/railtie" if defined?(Rails)
|
|
19
|
-
# The dashboard is a Rails engine; engines must be defined when the gem is
|
|
20
|
-
# required (before the host app collects railtie initializers), so it
|
|
21
|
-
# cannot be left to the Dashboard autoload above.
|
|
22
|
-
require "active_agent/dashboard" if defined?(Rails)
|
|
23
19
|
|
|
24
20
|
# ActiveAgent is a framework for building AI agents with Rails-like conventions.
|
|
25
21
|
#
|
|
@@ -97,6 +93,7 @@ module ActiveAgent
|
|
|
97
93
|
# These components are loaded on-demand when first referenced.
|
|
98
94
|
autoload :Base
|
|
99
95
|
autoload :Callbacks, "active_agent/concerns/callbacks"
|
|
96
|
+
autoload :Delegation, "active_agent/concerns/delegation"
|
|
100
97
|
autoload :Streaming, "active_agent/concerns/streaming"
|
|
101
98
|
autoload :InlinePreviewInterceptor
|
|
102
99
|
autoload :Generation
|
|
@@ -112,7 +109,6 @@ module ActiveAgent
|
|
|
112
109
|
autoload :Tooling, "active_agent/concerns/tooling"
|
|
113
110
|
autoload :View, "active_agent/concerns/view"
|
|
114
111
|
autoload :Telemetry
|
|
115
|
-
autoload :Dashboard
|
|
116
112
|
|
|
117
113
|
class << self
|
|
118
114
|
# Eagerly loads all ActiveAgent components and descendant agent classes.
|