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,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
- parameters[:stream] = process_stream if request.try(:stream)
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(&parameters[: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
- # Once we are finished, close out and run tooling callbacks (Recursive)
122
- process_prompt_finished
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
- # ActiveAgent::Dashboard.trace_model_class overrides. Idempotent on
82
- # trace_id, as HTTP ingest is.
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 active_agent:dashboard:install` first"
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?(ActiveAgent::Dashboard) && ActiveAgent::Dashboard.respond_to?(:trace_model)
102
- ActiveAgent::Dashboard.trace_model
103
- elsif defined?(ActiveAgent::TelemetryTrace)
104
- ActiveAgent::TelemetryTrace
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?(::ActiveAgent::Dashboard::Engine)
131
+ return nil unless defined?(::ActionAgent::Engine)
128
132
 
129
133
  route = route_set.routes.find do |candidate|
130
- mounted_engine(candidate.app) == ::ActiveAgent::Dashboard::Engine
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
- if (outbound = prompt_options[:messages]).present?
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
@@ -54,6 +54,7 @@ module ActiveAgent
54
54
  autoload :Span
55
55
  autoload :Reporter
56
56
  autoload :Instrumentation
57
+ autoload :ToolOrigin
57
58
 
58
59
  class << self
59
60
  # Returns the telemetry configuration instance.
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.1.0"
2
+ VERSION = "1.3.0"
3
3
  end
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.