actionagent 1.2.2 → 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 (54) 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 +48 -44
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
  6. data/app/controllers/action_agent/api/agents_controller.rb +76 -48
  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/evaluations_controller.rb +237 -7
  10. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  11. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  12. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  13. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  14. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  15. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  16. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  17. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  18. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  19. data/app/controllers/action_agent/application_controller.rb +25 -2
  20. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  21. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  22. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  23. data/app/jobs/action_agent/application_job.rb +7 -3
  24. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  25. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  26. data/app/models/action_agent/agent.rb +14 -5
  27. data/app/models/action_agent/agent_template.rb +22 -7
  28. data/app/models/action_agent/evaluation.rb +64 -4
  29. data/app/models/action_agent/evaluation_run.rb +182 -2
  30. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  31. data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
  32. data/app/models/action_agent/recording_action.rb +11 -7
  33. data/app/models/action_agent/sandbox_session.rb +1 -1
  34. data/app/models/action_agent/session_recording.rb +31 -8
  35. data/app/models/action_agent/telemetry_trace.rb +110 -2
  36. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  37. data/app/models/concerns/action_agent/ownable.rb +15 -2
  38. data/app/queries/action_agent/metrics_report.rb +498 -0
  39. data/app/services/action_agent/agent_toolbox.rb +4 -4
  40. data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
  41. data/app/services/action_agent/mcp_catalog.rb +46 -8
  42. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  43. data/app/services/action_agent/playwright_mcp_client.rb +6 -6
  44. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  45. data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
  46. data/app/services/action_agent/tool_discovery.rb +22 -8
  47. data/config/routes.rb +25 -2
  48. data/lib/action_agent/engine.rb +101 -19
  49. data/lib/action_agent/version.rb +1 -1
  50. data/lib/action_agent.rb +72 -6
  51. data/lib/generators/action_agent/install_generator.rb +20 -7
  52. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  53. data/lib/tasks/action_agent.rake +9 -0
  54. metadata +19 -6
@@ -81,6 +81,109 @@ module ActionAgent
81
81
  end
82
82
  end
83
83
 
84
+ # The trace columns the Metrics report reads, in the order
85
+ # pluck_metrics_rows returns them (before the span-derived fields).
86
+ METRICS_COLUMNS = %i[
87
+ timestamp agent_class agent_action total_duration_ms status error_message
88
+ total_input_tokens total_output_tokens
89
+ ].freeze
90
+
91
+ # Plucks one row per trace with everything the Metrics report needs, so
92
+ # a window is read once:
93
+ #
94
+ # [timestamp, agent_class, agent_action, total_duration_ms, status,
95
+ # error_message, total_input_tokens, total_output_tokens,
96
+ # llm_model, llm_provider, tool_calls, tool_errors]
97
+ #
98
+ # llm_model and llm_provider come from the first llm span; tool_calls
99
+ # and tool_errors count the trace's tool spans and those whose status is
100
+ # ERROR. Same split as pluck_with_llm_model: PostgreSQL digs into the
101
+ # spans jsonb in SQL, other adapters read the column back and count in
102
+ # Ruby.
103
+ def self.pluck_metrics_rows(scope)
104
+ if postgres?
105
+ scope.pluck(
106
+ *METRICS_COLUMNS,
107
+ # spans is cast for the same reason as in pluck_with_llm_model:
108
+ # the column is json on older installs.
109
+ Arel.sql(
110
+ "(SELECT s.value -> 'attributes' ->> 'llm.model' " \
111
+ "FROM jsonb_array_elements(spans::jsonb) AS s " \
112
+ "WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
113
+ ),
114
+ Arel.sql(
115
+ "(SELECT s.value -> 'attributes' ->> 'llm.provider' " \
116
+ "FROM jsonb_array_elements(spans::jsonb) AS s " \
117
+ "WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
118
+ ),
119
+ Arel.sql(
120
+ "(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
121
+ "WHERE s.value ->> 'type' = 'tool')"
122
+ ),
123
+ Arel.sql(
124
+ "(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
125
+ "WHERE s.value ->> 'type' = 'tool' AND s.value ->> 'status' = '#{STATUS_ERROR}')"
126
+ )
127
+ )
128
+ else
129
+ scope.pluck(:spans, *METRICS_COLUMNS).map do |spans, *rest|
130
+ spans = Array(spans).grep(Hash)
131
+ llm = spans.find { |span| span["type"].to_s == "llm" }
132
+ tools = spans.select { |span| span["type"].to_s == "tool" }
133
+ [
134
+ *rest,
135
+ llm&.dig("attributes", "llm.model"),
136
+ llm&.dig("attributes", "llm.provider"),
137
+ tools.size,
138
+ tools.count { |span| span["status"].to_s == STATUS_ERROR }
139
+ ]
140
+ end
141
+ end
142
+ end
143
+
144
+ # Per-tool call count, error count and summed duration over the tool
145
+ # spans of every trace in +scope+:
146
+ #
147
+ # { "web_search" => { calls: 12, errors: 1, total_ms: 7680.0 }, ... }
148
+ #
149
+ # A tool is named by its span's tool.name attribute, or by the span name
150
+ # minus its "tool." prefix — the same rule as #tool_usage. PostgreSQL
151
+ # groups in SQL over the unnested spans jsonb; other adapters read the
152
+ # spans back and tally in Ruby.
153
+ def self.tool_span_stats(scope)
154
+ if postgres?
155
+ name_sql = "COALESCE(s.value -> 'attributes' ->> 'tool.name', " \
156
+ "regexp_replace(COALESCE(s.value ->> 'name', ''), '^tool\\.', ''))"
157
+
158
+ scope
159
+ .joins("CROSS JOIN LATERAL jsonb_array_elements(#{table_name}.spans::jsonb) AS s")
160
+ .where("s.value ->> 'type' = 'tool'")
161
+ .group(Arel.sql(name_sql))
162
+ .pluck(
163
+ Arel.sql(name_sql),
164
+ Arel.sql("COUNT(*)"),
165
+ Arel.sql("SUM(CASE WHEN s.value ->> 'status' = '#{STATUS_ERROR}' THEN 1 ELSE 0 END)"),
166
+ Arel.sql("SUM(COALESCE((s.value ->> 'duration_ms')::float, 0))")
167
+ )
168
+ .to_h do |name, calls, errors, total_ms|
169
+ [ name, { calls: calls.to_i, errors: errors.to_i, total_ms: total_ms.to_f } ]
170
+ end
171
+ else
172
+ scope.pluck(:spans).each_with_object({}) do |spans, stats|
173
+ Array(spans).each do |span|
174
+ next unless span.is_a?(Hash) && span["type"].to_s == "tool"
175
+
176
+ attributes = span["attributes"] || {}
177
+ name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
178
+ entry = (stats[name] ||= { calls: 0, errors: 0, total_ms: 0.0 })
179
+ entry[:calls] += 1
180
+ entry[:errors] += 1 if span["status"].to_s == STATUS_ERROR
181
+ entry[:total_ms] += span["duration_ms"].to_f
182
+ end
183
+ end
184
+ end
185
+ end
186
+
84
187
  def self.create_from_payload(trace, sdk_info = {}, account: nil)
85
188
  spans = trace["spans"] || []
86
189
  root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
@@ -193,8 +296,13 @@ module ActionAgent
193
296
  duration_ms: span["duration_ms"],
194
297
  status: span["status"],
195
298
  error: attributes["error.message"],
196
- arguments: attributes["tool.input.args"],
197
- result: attributes["tool.output.result"]
299
+ # Two key families: the framework's instrumentation records
300
+ # tool.input.args / tool.output.result, the activeagents-telemetry
301
+ # ruby_llm adapter records tool.arguments / tool.result. The
302
+ # Interactions serializer already reads both; the Tools view's
303
+ # sample arguments come from here and were blank for adapter traffic.
304
+ arguments: attributes["tool.input.args"] || attributes["tool.arguments"],
305
+ result: attributes["tool.output.result"] || attributes["tool.result"]
198
306
  }
199
307
  end
200
308
  end
@@ -45,6 +45,25 @@ module ActionAgent
45
45
 
46
46
  Time.find_zone("UTC").parse(value.to_s).to_i
47
47
  end
48
+
49
+ # Epoch second of the +seconds+-wide bucket holding +value+. Buckets
50
+ # are aligned to the Unix epoch, so the same timestamp lands in the
51
+ # same bucket whatever form an adapter produced it in: a Time
52
+ # (PostgreSQL, or any adapter's type-cast pluck), the UTC string
53
+ # SQLite and MySQL return from raw SQL, or an epoch number.
54
+ def bucket_epoch(value, seconds)
55
+ epoch = value.is_a?(Numeric) ? value.to_i : hour_bucket_epoch(value)
56
+ epoch - (epoch % seconds.to_i)
57
+ end
58
+
59
+ # Start epochs of the +count+ consecutive +seconds+-wide buckets that
60
+ # end with the one containing +now+, oldest first. The last bucket is
61
+ # the live one, so the window these describe is
62
+ # [starts.first, starts.last + seconds) — it runs a little past now.
63
+ def bucket_starts(now, seconds, count)
64
+ last = bucket_epoch(now, seconds)
65
+ Array.new(count) { |index| last - ((count - 1 - index) * seconds) }
66
+ end
48
67
  end
49
68
  end
50
69
  end
@@ -71,16 +71,29 @@ module ActionAgent
71
71
  end
72
72
 
73
73
  # The record's owner under the current configuration, or nil.
74
+ #
75
+ # The belongs_to is declared when the class loads, from the
76
+ # configuration at that moment; an owner model configured afterwards
77
+ # (a test, or an initializer that ran late) has the column but not the
78
+ # association, so the foreign key is read directly in that case.
74
79
  def owner
75
80
  association = self.class.owner_association
76
- association && public_send(association)
81
+ return nil unless association
82
+ return public_send(association) if respond_to?(association)
83
+
84
+ owner_class = ActionAgent.public_send(CLASS_FOR.fetch(association)).safe_constantize
85
+ owner_id = self[:"#{association}_id"]
86
+ owner_class.find_by(id: owner_id) if owner_class && owner_id
77
87
  end
78
88
 
79
89
  # Assigns +owner+ to whichever association this install uses. A no-op
80
90
  # when the host app configured no owner model.
81
91
  def owner=(record)
82
92
  association = self.class.owner_association
83
- public_send(:"#{association}=", record) if association
93
+ return unless association
94
+ return public_send(:"#{association}=", record) if respond_to?(:"#{association}=")
95
+
96
+ self[:"#{association}_id"] = record&.id
84
97
  end
85
98
  end
86
99
  end
@@ -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