actionagent 1.2.2 → 1.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. metadata +22 -5
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The Metrics view's service overview: one window of traces bucketed into
5
+ # a time series, plus the totals, previous-period deltas, top lists and
6
+ # chart markers the golden-signal tiles, chart panels and right rail draw.
7
+ #
8
+ # The window is read once (TelemetryTrace.pluck_metrics_rows) and then
9
+ # bucketed, classified and percentiled in Ruby, so PostgreSQL and SQLite
10
+ # produce the same numbers. The previous period is read the same way for
11
+ # the deltas, and the tools rail is one grouped read over tool spans.
12
+ # Latency is the trace's total_duration_ms (nearest-rank percentiles);
13
+ # cost is ModelPricing's estimate per trace from the first llm span's
14
+ # model.
15
+ class MetricsReport
16
+ # range => [bucket seconds, bucket count]
17
+ RANGES = {
18
+ "1h" => [ 60, 60 ],
19
+ "24h" => [ 900, 96 ],
20
+ "7d" => [ 7200, 84 ]
21
+ }.freeze
22
+ DEFAULT_RANGE = "24h"
23
+ CUSTOM_RANGE = "custom"
24
+ MAX_WINDOW_HOURS = 24 * 30
25
+
26
+ # A bare `hours` window is cut into about this many buckets, using the
27
+ # smallest of these sizes that gets there.
28
+ TARGET_BUCKETS = 96
29
+ BUCKET_SIZES = [ 60, 120, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400 ].freeze
30
+
31
+ # Error classes in display order. classify_error decides in this order
32
+ # too, except that "tool error" is read off span status rather than the
33
+ # message and so is checked first.
34
+ ERROR_TYPES = [ "429 rate limit", "timeout", "tool error", "provider 5xx", "other" ].freeze
35
+ ERROR_PATTERNS = {
36
+ "429 rate limit" => /\b429\b|rate.?limit|too many requests|quota/i,
37
+ "timeout" => /timed?\s?out|timeout|deadline/i,
38
+ "provider 5xx" => /\b5\d\d\b|overloaded|internal server error|bad gateway|service unavailable|upstream/i
39
+ }.freeze
40
+
41
+ TOP_MODELS = 8
42
+ TOP_ACTIONS = 5
43
+ TOP_TOOLS = 5
44
+
45
+ # A bucket is the window's incident when it has at least this many
46
+ # errors and an error rate at least this many times the window's.
47
+ INCIDENT_MIN_ERRORS = 5
48
+ INCIDENT_RATE_FACTOR = 2
49
+
50
+ # One trace, as TelemetryTrace.pluck_metrics_rows returns it.
51
+ Row = Struct.new(
52
+ :timestamp, :agent_class, :agent_action, :duration_ms, :status, :error_message,
53
+ :input_tokens, :output_tokens, :model, :provider, :tool_calls, :tool_errors
54
+ )
55
+
56
+ # Running totals for one slice of the window — the whole window, a
57
+ # bucket, an agent, a model or an action. Every slice keeps the same
58
+ # counters so one pass over the rows can feed all of them.
59
+ class Tally
60
+ attr_reader :requests, :errors, :errors_by_type, :errors_by_agent, :requests_by_agent,
61
+ :tokens_in, :tokens_out, :cost, :tool_calls, :tool_errors
62
+
63
+ def initialize
64
+ @requests = 0
65
+ @durations = []
66
+ @sorted = nil
67
+ @errors = 0
68
+ @errors_by_type = Hash.new(0)
69
+ @errors_by_agent = Hash.new(0)
70
+ @requests_by_agent = Hash.new(0)
71
+ @tokens_in = 0
72
+ @tokens_out = 0
73
+ @cost = 0.0
74
+ @tool_calls = 0
75
+ @tool_errors = 0
76
+ end
77
+
78
+ # @param error_type [String, nil] one of ERROR_TYPES for an ERROR trace
79
+ def add(row, error_type:, cost:)
80
+ @requests += 1
81
+ if row.duration_ms
82
+ @durations << row.duration_ms
83
+ @sorted = nil
84
+ end
85
+ @requests_by_agent[row.agent_class] += 1 if row.agent_class
86
+ if error_type
87
+ @errors += 1
88
+ @errors_by_type[error_type] += 1
89
+ @errors_by_agent[row.agent_class] += 1 if row.agent_class
90
+ end
91
+ @tokens_in += row.input_tokens.to_i
92
+ @tokens_out += row.output_tokens.to_i
93
+ @cost += cost
94
+ @tool_calls += row.tool_calls.to_i
95
+ @tool_errors += row.tool_errors.to_i
96
+ end
97
+
98
+ def tokens = @tokens_in + @tokens_out
99
+
100
+ def error_rate = MetricsReport.rate(@errors, @requests)
101
+
102
+ def tool_error_rate = MetricsReport.rate(@tool_errors, @tool_calls)
103
+
104
+ # Nearest-rank percentile of the recorded durations, in whole
105
+ # milliseconds; nil when nothing in the slice carried a duration.
106
+ def percentile(pct)
107
+ @sorted ||= @durations.sort
108
+ MetricsReport.percentile(@sorted, pct)&.round
109
+ end
110
+ end
111
+
112
+ Aggregate = Struct.new(:window, :buckets, :by_agent, :by_model, :by_action, keyword_init: true)
113
+
114
+ # Which ERROR_TYPES class an ERROR trace belongs to, first match wins: a
115
+ # tool span that errored makes it a tool error whatever the message
116
+ # says, otherwise the message decides, and anything unrecognised is
117
+ # "other".
118
+ #
119
+ # @param error_message [String, nil] the trace's error_message
120
+ # @param tool_errors [Integer] how many of its tool spans errored
121
+ def self.classify_error(error_message, tool_errors: 0)
122
+ return "tool error" if tool_errors.to_i.positive?
123
+
124
+ message = error_message.to_s
125
+ ERROR_PATTERNS.each { |type, pattern| return type if message.match?(pattern) }
126
+ "other"
127
+ end
128
+
129
+ # Nearest-rank percentile of +sorted+ (ascending): the value at rank
130
+ # ceil(p/100 × n). nil for an empty list.
131
+ def self.percentile(sorted, pct)
132
+ return nil if sorted.empty?
133
+
134
+ rank = (sorted.size * pct / 100.0).ceil
135
+ sorted[rank.clamp(1, sorted.size) - 1]
136
+ end
137
+
138
+ # +part+ as a percentage of +whole+, two decimals; 0.0 when whole is 0.
139
+ def self.rate(part, whole)
140
+ whole.to_i.positive? ? (part.to_f / whole * 100).round(2) : 0.0
141
+ end
142
+
143
+ # [range, bucket_seconds, bucket_count, window_hours]. A named range
144
+ # wins; a bare +hours+ is a custom window bucketed to about
145
+ # TARGET_BUCKETS points; neither means the default range.
146
+ def self.resolve_range(range, hours)
147
+ if RANGES.key?(range.to_s)
148
+ seconds, count = RANGES[range.to_s]
149
+ return [ range.to_s, seconds, count, (seconds * count) / 3600 ]
150
+ end
151
+ return resolve_range(DEFAULT_RANGE, nil) if hours.blank?
152
+
153
+ window_hours = hours.to_i.clamp(1, MAX_WINDOW_HOURS)
154
+ window_seconds = window_hours * 3600
155
+ bucket_count = ->(size) { (window_seconds.to_f / size).ceil }
156
+ seconds = BUCKET_SIZES.find { |size| bucket_count.call(size) <= TARGET_BUCKETS } || BUCKET_SIZES.last
157
+ [ CUSTOM_RANGE, seconds, bucket_count.call(seconds), window_hours ]
158
+ end
159
+
160
+ attr_reader :range, :bucket_seconds, :bucket_count, :window_hours, :agent
161
+
162
+ # @param traces [ActiveRecord::Relation] every trace the caller can see
163
+ # @param agents [ActiveRecord::Relation, Array<Agent>] the caller's
164
+ # agents, whose versions become deploy markers
165
+ # @param range [String, nil] "1h" | "24h" | "7d"
166
+ # @param hours [Integer, String, nil] a custom window, used when
167
+ # +range+ is absent or unknown
168
+ # @param agent [String, nil] an agent_class to narrow everything to
169
+ # @param now [Time] the end of the window
170
+ def initialize(traces:, agents:, range: nil, hours: nil, agent: nil, now: Time.current)
171
+ @traces = traces
172
+ @agents = agents
173
+ @agent = agent.presence
174
+ @now = now
175
+ @range, @bucket_seconds, @bucket_count, @window_hours = self.class.resolve_range(range, hours)
176
+ @starts = trace_model.bucket_starts(now, @bucket_seconds, @bucket_count)
177
+ end
178
+
179
+ def window_minutes = (@bucket_seconds * @bucket_count) / 60
180
+
181
+ def window_start = Time.at(@starts.first).utc
182
+
183
+ # A little past +now+: the last bucket is the live one.
184
+ def window_end = Time.at(@starts.last + @bucket_seconds).utc
185
+
186
+ def previous_start = Time.at(@starts.first - (@bucket_seconds * @bucket_count)).utc
187
+
188
+ def to_h
189
+ {
190
+ range: @range,
191
+ bucket_seconds: @bucket_seconds,
192
+ window_minutes: window_minutes,
193
+ agent: @agent,
194
+ environment: environment,
195
+ totals: totals,
196
+ deltas: deltas,
197
+ series: series,
198
+ agents: agents_rail,
199
+ models: models_rail,
200
+ actions: actions_rail,
201
+ tools: tools_rail,
202
+ errors_by_type: errors_by_type,
203
+ markers: markers
204
+ }
205
+ end
206
+
207
+ def totals
208
+ window = current.window
209
+ {
210
+ requests: window.requests,
211
+ requests_per_minute: (window.requests.to_f / window_minutes).round(2),
212
+ p50_ms: window.percentile(50),
213
+ p95_ms: window.percentile(95),
214
+ p99_ms: window.percentile(99),
215
+ errors: window.errors,
216
+ error_rate: window.error_rate,
217
+ tokens_in: window.tokens_in,
218
+ tokens_out: window.tokens_out,
219
+ tokens: window.tokens,
220
+ cost: window.cost.round(4),
221
+ cost_per_request: window.requests.positive? ? (window.cost / window.requests).round(6) : 0.0,
222
+ tool_calls: window.tool_calls,
223
+ tool_errors: window.tool_errors,
224
+ tool_error_rate: window.tool_error_rate
225
+ }
226
+ end
227
+
228
+ # Against the period of the same length just before the window. A
229
+ # percentage change is nil when the previous value is 0 or absent; the
230
+ # error-rate delta is in points and is nil only when the previous
231
+ # period had no requests (no rate to compare against).
232
+ def deltas
233
+ window = current.window
234
+ {
235
+ requests_pct: percent_change(previous.requests, window.requests),
236
+ p50_pct: percent_change(previous.percentile(50), window.percentile(50)),
237
+ error_rate_pt: previous.requests.positive? ? (window.error_rate - previous.error_rate).round(2) : nil,
238
+ tokens_pct: percent_change(previous.tokens, window.tokens),
239
+ cost_pct: percent_change(previous.cost, window.cost)
240
+ }
241
+ end
242
+
243
+ # One entry per bucket, oldest first. Percentiles are nil for a bucket
244
+ # with no requests; errors_by_type always carries every ERROR_TYPES
245
+ # key so stacked bars have a stable shape.
246
+ def series
247
+ current.buckets.each_with_index.map do |bucket, index|
248
+ {
249
+ ts: Time.at(@starts[index]).utc.iso8601,
250
+ requests: bucket.requests,
251
+ requests_by_agent: bucket.requests_by_agent,
252
+ p50_ms: bucket.percentile(50),
253
+ p95_ms: bucket.percentile(95),
254
+ p99_ms: bucket.percentile(99),
255
+ errors: bucket.errors,
256
+ errors_by_type: ERROR_TYPES.to_h { |type| [ type, bucket.errors_by_type[type] ] },
257
+ tokens_in: bucket.tokens_in,
258
+ tokens_out: bucket.tokens_out,
259
+ cost: bucket.cost.round(4),
260
+ tool_calls: bucket.tool_calls,
261
+ tool_errors: bucket.tool_errors
262
+ }
263
+ end
264
+ end
265
+
266
+ # Requests descending. Traces with no agent_class are counted in the
267
+ # totals but have no row here: a row is a filter target, and there is
268
+ # no agent_class to filter by.
269
+ def agents_rail
270
+ total = current.window.requests
271
+ current.by_agent.map do |name, tally|
272
+ {
273
+ name: name,
274
+ requests: tally.requests,
275
+ share_pct: self.class.rate(tally.requests, total),
276
+ p95_ms: tally.percentile(95),
277
+ error_rate: tally.error_rate,
278
+ cost: tally.cost.round(4),
279
+ tokens: tally.tokens
280
+ }
281
+ end.sort_by { |row| [ -row[:requests], row[:name] ] }
282
+ end
283
+
284
+ # Tokens descending, top TOP_MODELS; share is of the window's tokens.
285
+ def models_rail
286
+ total = current.window.tokens
287
+ current.by_model.map do |(model, provider), tally|
288
+ {
289
+ model: model,
290
+ provider: provider,
291
+ requests: tally.requests,
292
+ tokens: tally.tokens,
293
+ share_pct: self.class.rate(tally.tokens, total),
294
+ cost: tally.cost.round(4)
295
+ }
296
+ end.sort_by { |row| [ -row[:tokens], -row[:requests], row[:model] ] }.first(TOP_MODELS)
297
+ end
298
+
299
+ # p95 descending, top TOP_ACTIONS. An action none of whose traces
300
+ # carried a duration cannot be ranked and is left out.
301
+ def actions_rail
302
+ current.by_action.filter_map do |(agent_class, action), tally|
303
+ p95 = tally.percentile(95)
304
+ next unless p95
305
+
306
+ { name: "#{agent_class}##{action}", agent: agent_class, requests: tally.requests, p95_ms: p95 }
307
+ end.sort_by { |row| [ -row[:p95_ms], -row[:requests], row[:name] ] }.first(TOP_ACTIONS)
308
+ end
309
+
310
+ # Calls descending, top TOP_TOOLS, from the window's tool spans.
311
+ def tools_rail
312
+ trace_model.tool_span_stats(window_scope).map do |name, stats|
313
+ calls = stats[:calls].to_i
314
+ {
315
+ name: name,
316
+ calls: calls,
317
+ avg_ms: calls.positive? ? (stats[:total_ms].to_f / calls).round : 0,
318
+ error_rate: self.class.rate(stats[:errors], calls)
319
+ }
320
+ end.sort_by { |row| [ -row[:calls], row[:name] ] }.first(TOP_TOOLS)
321
+ end
322
+
323
+ # Every ERROR_TYPES class in order, zero counts included.
324
+ def errors_by_type
325
+ ERROR_TYPES.map { |type| { type: type, count: current.window.errors_by_type[type] } }
326
+ end
327
+
328
+ # Deploys (agent versions created inside the window) and at most one
329
+ # incident (the bucket with the error spike), by time. Ruby's sort is
330
+ # not stable, so ties (versions saved in the same second) keep their
331
+ # creation order explicitly.
332
+ def markers
333
+ (deploy_markers + incident_markers)
334
+ .each_with_index
335
+ .sort_by { |marker, position| [ marker[:ts], position ] }
336
+ .map(&:first)
337
+ end
338
+
339
+ # The environment most of the window's traces report, nil when none do.
340
+ def environment
341
+ counts = window_scope.group(:environment).count.reject { |env, _count| env.blank? }
342
+ counts.min_by { |env, count| [ -count, env ] }&.first
343
+ end
344
+
345
+ private
346
+
347
+ def trace_model = ActionAgent.trace_model
348
+
349
+ def window_scope = narrowed(@traces.where(timestamp: window_start...window_end))
350
+
351
+ def previous_scope = narrowed(@traces.where(timestamp: previous_start...window_start))
352
+
353
+ def narrowed(scope) = @agent ? scope.where(agent_class: @agent) : scope
354
+
355
+ # The one pass over the window: every row feeds the window total, its
356
+ # bucket, its agent, its model and its action.
357
+ def current
358
+ @current ||= begin
359
+ aggregate = Aggregate.new(
360
+ window: Tally.new,
361
+ buckets: Array.new(@bucket_count) { Tally.new },
362
+ by_agent: Hash.new { |hash, key| hash[key] = Tally.new },
363
+ by_model: Hash.new { |hash, key| hash[key] = Tally.new },
364
+ by_action: Hash.new { |hash, key| hash[key] = Tally.new }
365
+ )
366
+
367
+ rows_for(window_scope).each do |row|
368
+ index = bucket_index(row.timestamp)
369
+ next unless index
370
+
371
+ error_type = error_type_for(row)
372
+ cost = cost_for(row)
373
+
374
+ aggregate.window.add(row, error_type: error_type, cost: cost)
375
+ aggregate.buckets[index].add(row, error_type: error_type, cost: cost)
376
+ aggregate.by_agent[row.agent_class].add(row, error_type: error_type, cost: cost) if row.agent_class
377
+ aggregate.by_model[[ row.model || "unknown", row.provider ]].add(row, error_type: error_type, cost: cost)
378
+ if row.agent_class && row.agent_action
379
+ aggregate.by_action[[ row.agent_class, row.agent_action ]].add(row, error_type: error_type, cost: cost)
380
+ end
381
+ end
382
+
383
+ aggregate
384
+ end
385
+ end
386
+
387
+ # The previous period only feeds the deltas, so one tally is enough.
388
+ def previous
389
+ @previous ||= rows_for(previous_scope).each_with_object(Tally.new) do |row, tally|
390
+ tally.add(row, error_type: error_type_for(row), cost: cost_for(row))
391
+ end
392
+ end
393
+
394
+ def rows_for(scope)
395
+ trace_model.pluck_metrics_rows(scope).map { |values| Row.new(*values) }
396
+ end
397
+
398
+ def bucket_index(timestamp)
399
+ index = (trace_model.bucket_epoch(timestamp, @bucket_seconds) - @starts.first) / @bucket_seconds
400
+ index if index >= 0 && index < @bucket_count
401
+ end
402
+
403
+ def error_type_for(row)
404
+ return nil unless row.status == TelemetryTrace::STATUS_ERROR
405
+
406
+ self.class.classify_error(row.error_message, tool_errors: row.tool_errors)
407
+ end
408
+
409
+ def cost_for(row)
410
+ ModelPricing.estimate(model: row.model, input_tokens: row.input_tokens, output_tokens: row.output_tokens) || 0.0
411
+ end
412
+
413
+ def percent_change(before, after)
414
+ return nil if before.nil? || after.nil? || before.to_f.zero?
415
+
416
+ (((after.to_f - before.to_f) / before.to_f) * 100).round(2)
417
+ end
418
+
419
+ # One marker per agent version created inside the window, for the
420
+ # caller's agents (narrowed to the filtered agent's versions when a
421
+ # filter is on). A version that changed the instructions is labelled
422
+ # as such — that is the deploy a latency or error shift most often
423
+ # traces back to.
424
+ #
425
+ # The comparison needs each version's predecessor. Two reads cover
426
+ # them all: the window's versions, then the predecessors that fall
427
+ # before the window (a predecessor inside it is already loaded).
428
+ # Version numbers are sequential per agent — Agent creates each one
429
+ # as latest + 1 and they are only destroyed with their agent — so the
430
+ # predecessor of version n is version n - 1.
431
+ def deploy_markers
432
+ agent_ids = @agents.respond_to?(:pluck) ? @agents.pluck(:id) : Array(@agents).map(&:id)
433
+ versions = AgentVersion
434
+ .includes(:agent)
435
+ .where(agent_id: agent_ids, created_at: window_start...window_end)
436
+ .order(:created_at, :id)
437
+ .to_a
438
+ predecessors = predecessors_of(versions)
439
+
440
+ versions.filter_map do |version|
441
+ agent = version.agent
442
+ next if agent.nil?
443
+ next if @agent && agent.telemetry_agent_class != @agent
444
+
445
+ previous = predecessors[[ version.agent_id, version.version_number - 1 ]]
446
+ instructions_changed = snapshot_of(version)["instructions"].to_s != snapshot_of(previous)["instructions"].to_s
447
+ {
448
+ kind: "deploy",
449
+ ts: version.created_at.utc.iso8601,
450
+ label: "#{instructions_changed ? 'instructions ' : ''}v#{version.version_number} · #{agent.name}",
451
+ agent: agent.name
452
+ }
453
+ end
454
+ end
455
+
456
+ # { [agent_id, version_number] => version } holding the predecessor of
457
+ # every version in +versions+: the ones among them, plus one read for
458
+ # the rest.
459
+ def predecessors_of(versions)
460
+ loaded = versions.index_by { |version| [ version.agent_id, version.version_number ] }
461
+ missing = versions
462
+ .map { |version| [ version.agent_id, version.version_number - 1 ] }
463
+ .reject { |key| key.last < 1 || loaded.key?(key) }
464
+ return loaded if missing.empty?
465
+
466
+ missing
467
+ .group_by(&:first)
468
+ .map { |agent_id, keys| AgentVersion.where(agent_id: agent_id, version_number: keys.map(&:last)) }
469
+ .reduce(:or)
470
+ .each_with_object(loaded) { |version, map| map[[ version.agent_id, version.version_number ]] = version }
471
+ end
472
+
473
+ # Snapshots are written with symbol keys and read back with strings.
474
+ def snapshot_of(version)
475
+ (version&.configuration_snapshot || {}).to_h.transform_keys(&:to_s)
476
+ end
477
+
478
+ # The bucket with the most errors, when it is a real spike: at least
479
+ # INCIDENT_MIN_ERRORS errors and INCIDENT_RATE_FACTOR times the window's
480
+ # error rate. Labelled by its dominant error class and attributed to
481
+ # the agent that errored most in it.
482
+ def incident_markers
483
+ window_rate = current.window.error_rate
484
+ return [] unless window_rate.positive?
485
+
486
+ buckets = current.buckets
487
+ index = buckets.each_index.max_by { |i| [ buckets[i].errors, -i ] }
488
+ bucket = buckets[index]
489
+ return [] if bucket.errors < INCIDENT_MIN_ERRORS
490
+ return [] if bucket.error_rate < window_rate * INCIDENT_RATE_FACTOR
491
+
492
+ type, _position = ERROR_TYPES.each_with_index.max_by { |name, i| [ bucket.errors_by_type[name], -i ] }
493
+ agent_class, _count = bucket.errors_by_agent.min_by { |name, count| [ -count, name ] }
494
+
495
+ [ { kind: "incident", ts: Time.at(@starts[index]).utc.iso8601, label: "#{type} spike", agent: agent_class } ]
496
+ end
497
+ end
498
+ end
@@ -15,6 +15,7 @@ module ActionAgent
15
15
  tool_arguments: message.tool_arguments.presence,
16
16
  tool_result: message.tool_result,
17
17
  duration_ms: message.metadata&.dig("duration_ms"),
18
+ attachments: message.attachments || [],
18
19
  content_checksum: message.content_checksum,
19
20
  created_at: message.created_at.iso8601(3)
20
21
  }