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
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionAgent
4
4
  class Agent < ApplicationRecord
5
+ class ObservedAgentError < StandardError; end
6
+
5
7
  include Ownable
6
8
  owned_by :user, :account
7
9
 
@@ -72,7 +74,7 @@ module ActionAgent
72
74
 
73
75
  # Available tools/MCPs
74
76
  AVAILABLE_TOOLS = %w[
75
- terminal playwright filesystem code database slack fetch search edit translate memory agents
77
+ terminal playwright filesystem code database slack fetch search edit translate memory agents ui
76
78
  ].freeze
77
79
 
78
80
  # Available providers
@@ -181,28 +183,34 @@ module ActionAgent
181
183
  agent_versions.count
182
184
  end
183
185
 
184
- # Generate Ruby agent class code
186
+ # Generate Ruby agent class code.
187
+ #
188
+ # The class is named by telemetry_agent_class: it parameterizes a name
189
+ # with spaces ("My Agent" -> MyAgentAgent, not `class My AgentAgent`) and
190
+ # appends the Agent suffix only when it is missing, so an observed agent
191
+ # whose reported class already ends in Agent is not doubled. It is also
192
+ # the key traces are correlated on, so the exported class reports under
193
+ # the same name this record listens for.
185
194
  def to_agent_class_code
186
195
  <<~RUBY
187
- class #{agent_class_name || name.camelize}Agent < ApplicationAgent
196
+ class #{telemetry_agent_class} < ApplicationAgent
188
197
  generate_with :#{provider}, model: "#{model}"#{model_config_code}
189
198
 
190
199
  def perform
191
- prompt#{instructions_code}
200
+ #{instructions_code}
192
201
  end
193
202
  end
194
203
  RUBY
195
204
  end
196
205
 
197
- # Execute a run with this agent
198
- def execute(input_prompt, action: nil, **params)
199
- run = agent_runs.create!(
200
- input_prompt: input_prompt,
201
- action_name: normalized_action(action),
202
- input_params: params,
203
- status: :pending,
204
- trace_id: SecureRandom.uuid
205
- )
206
+ # Execute a run with this agent. Files in +attachments+ (uploaded files,
207
+ # {io:, filename:, content_type:} hashes or blobs) are stored on the run
208
+ # before the job is enqueued, so a worker on another machine finds them
209
+ # attached. +params+ (provider/model overrides, the context_id of a
210
+ # conversation to continue) are kept on the run as input_params.
211
+ def execute(input_prompt, action: nil, attachments: [], **params)
212
+ ensure_executable!
213
+ run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
206
214
 
207
215
  # Queue the execution job
208
216
  AgentExecutionJob.perform_later(run.id)
@@ -211,14 +219,11 @@ module ActionAgent
211
219
  end
212
220
 
213
221
  # Quick test execution (synchronous)
214
- def test_execute(input_prompt, action: nil, **params)
215
- run = agent_runs.create!(
216
- input_prompt: input_prompt,
217
- action_name: normalized_action(action),
218
- input_params: params,
219
- status: :running,
220
- trace_id: SecureRandom.uuid,
221
- started_at: Time.current
222
+ def test_execute(input_prompt, action: nil, attachments: [], **params)
223
+ ensure_executable!
224
+ run = create_run(
225
+ input_prompt, action: action, attachments: attachments, params: params,
226
+ status: :running, started_at: Time.current
222
227
  )
223
228
 
224
229
  begin
@@ -247,8 +252,52 @@ module ActionAgent
247
252
  run
248
253
  end
249
254
 
255
+ # An observed agent was reconstructed from telemetry, so it is read-only:
256
+ # editing it would rewrite a record of what ran. Executing it is a
257
+ # different question — it carries the instructions, model and MCP servers a
258
+ # run needs, and evaluating the agent that actually served production is
259
+ # the case operators ask for. So a run is allowed once the agent names a
260
+ # server the dashboard can reach, and refused when it would have nothing to
261
+ # call.
262
+ def ensure_executable!
263
+ return unless observed?
264
+ return if MCPToolDispatcher.new(self).any_reachable_server?
265
+
266
+ raise ObservedAgentError,
267
+ "This agent was observed from telemetry and is read-only: it names no reachable MCP server — duplicate it to create an executable copy"
268
+ end
269
+
250
270
  private
251
271
 
272
+ # Refuses files before creating anything: a run that exists but lost
273
+ # its attachments would execute against the wrong prompt.
274
+ def create_run(input_prompt, action:, attachments:, params:, **attributes)
275
+ files = Array.wrap(attachments).compact
276
+ raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
277
+
278
+ run = agent_runs.create!(
279
+ input_prompt: input_prompt,
280
+ action_name: normalized_action(action),
281
+ input_params: params,
282
+ trace_id: SecureRandom.uuid,
283
+ **attributes
284
+ )
285
+
286
+ if files.any?
287
+ begin
288
+ run.attachments.attach(*files)
289
+ rescue StandardError
290
+ # An attach that raises (an unwritable service, a value that is not
291
+ # a file) happens after the row exists, and the caller never reaches
292
+ # the enqueue: without this the run would sit pending forever.
293
+ run.destroy
294
+ raise
295
+ end
296
+ end
297
+
298
+ run
299
+ end
300
+
252
301
  def slug_unique_within_owner
253
302
  return if slug.blank?
254
303
 
@@ -334,10 +383,12 @@ module ActionAgent
334
383
  ", #{configs}"
335
384
  end
336
385
 
386
+ # Exactly one prompt call: a bare `prompt` without instructions, or a
387
+ # single `prompt instructions:` heredoc with them.
337
388
  def instructions_code
338
- return "" if instructions.blank?
389
+ return "prompt" if instructions.blank?
339
390
 
340
- "\n prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
391
+ "prompt instructions: <<~INSTRUCTIONS\n #{instructions.gsub("\n", "\n ")}\n INSTRUCTIONS"
341
392
  end
342
393
  end
343
394
  end
@@ -4,6 +4,49 @@ module ActionAgent
4
4
  class AgentRun < ApplicationRecord
5
5
  belongs_to :agent
6
6
 
7
+ # Raised when a caller hands a run files to attach in a host app that
8
+ # has nowhere to keep them.
9
+ class AttachmentsUnavailable < StandardError
10
+ def initialize(message = "Attachments need Active Storage in the host app (run `rails active_storage:install`)")
11
+ super
12
+ end
13
+ end
14
+
15
+ # Files uploaded with the run. The execution service delivers them to
16
+ # the model (images and PDFs as data URIs, text inlined) and the
17
+ # persisted user message keeps a manifest of them.
18
+ #
19
+ # Guarded like RecordingSnapshot: a host app created with
20
+ # --skip-active-storage has no has_many_attached to call.
21
+ has_many_attached :attachments if defined?(ActiveStorage)
22
+
23
+ # Whether runs can carry files in this host app: Active Storage loaded,
24
+ # the macro applied, and its tables migrated. Never raises — a host
25
+ # that skipped `rails active_storage:install` still runs agents, it
26
+ # just can't attach files to them.
27
+ def self.attachments_available?
28
+ defined?(ActiveStorage) && method_defined?(:attachments) && ActiveStorage::Blob.table_exists?
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ # How an attachment reaches the model, by MIME type with a filename
34
+ # fallback for the text formats browsers upload as octet-stream:
35
+ # images and documents ride along as data URIs, text is inlined into
36
+ # the prompt, anything else is only described.
37
+ TEXT_CONTENT_TYPES = %w[application/json application/xml application/x-yaml application/csv].freeze
38
+ TEXT_EXTENSIONS = %w[.csv .md .txt .json .yml .yaml].freeze
39
+
40
+ def self.attachment_kind(content_type, filename = nil)
41
+ type = content_type.to_s.downcase
42
+ return "image" if type.start_with?("image/")
43
+ return "document" if type == "application/pdf"
44
+ return "text" if type.start_with?("text/") || TEXT_CONTENT_TYPES.include?(type)
45
+ return "text" if TEXT_EXTENSIONS.include?(File.extname(filename.to_s).downcase)
46
+
47
+ "file"
48
+ end
49
+
7
50
  # Status enum
8
51
  enum :status, { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }
9
52
 
@@ -103,6 +146,37 @@ module ActionAgent
103
146
  complete? || failed? || cancelled?
104
147
  end
105
148
 
149
+ # The conversation this run belongs to: the one it actually wrote to
150
+ # once it has executed (output_metadata), else the one the caller asked
151
+ # to continue. A pinned id the run declined — another agent's context,
152
+ # or another action's — must not be the id the API reports, or the
153
+ # runner would open a conversation the turn is not in.
154
+ def context_id
155
+ output_metadata&.dig("context_id") || input_params&.dig("context_id")
156
+ end
157
+
158
+ # The run's files as the runner and the persisted user message show
159
+ # them: one hash per attachment, with the kind the execution service
160
+ # sorted it into and a blob URL for thumbnails (nil when the host app
161
+ # didn't draw Active Storage's routes). Empty without Active Storage.
162
+ def attachment_manifest
163
+ return [] unless self.class.attachments_available?
164
+
165
+ attachment_records.map do |attachment|
166
+ blob = attachment.blob
167
+ {
168
+ "id" => attachment.id,
169
+ "blob_id" => blob.id,
170
+ "signed_id" => blob.signed_id,
171
+ "filename" => blob.filename.to_s,
172
+ "content_type" => blob.content_type,
173
+ "byte_size" => blob.byte_size,
174
+ "kind" => self.class.attachment_kind(blob.content_type, blob.filename.to_s),
175
+ "url" => blob_path(blob)
176
+ }
177
+ end
178
+ end
179
+
106
180
  # Get a summary for display
107
181
  def summary
108
182
  {
@@ -118,6 +192,8 @@ module ActionAgent
118
192
  instructions_digest: instructions_digest,
119
193
  instructions_codename: instructions_codename,
120
194
  instructions_preview: output_metadata&.dig("instructions")&.truncate(120),
195
+ attachments: attachment_manifest,
196
+ context_id: context_id,
121
197
  created_at: created_at,
122
198
  error: error_message
123
199
  }
@@ -147,5 +223,28 @@ module ActionAgent
147
223
  def set_trace_id
148
224
  self.trace_id ||= SecureRandom.uuid
149
225
  end
226
+
227
+ # Reads the association as loaded when a list preloaded it
228
+ # (with_attachments below), so serializing a page of runs costs two
229
+ # queries rather than two per run; a single run fetches its own.
230
+ def attachment_records
231
+ if attachments_attachments.loaded?
232
+ attachments_attachments.sort_by(&:id)
233
+ else
234
+ attachments_attachments.includes(:blob).order(:id).to_a
235
+ end
236
+ end
237
+
238
+ # Preloads attachments and blobs for a list of runs — a no-op scope in
239
+ # a host without Active Storage, so callers need no guard of their own.
240
+ def self.with_attachments
241
+ attachments_available? ? with_attached_attachments : all
242
+ end
243
+
244
+ def blob_path(blob)
245
+ Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
246
+ rescue StandardError
247
+ nil
248
+ end
150
249
  end
151
250
  end
@@ -24,10 +24,14 @@ module ActionAgent
24
24
  automation
25
25
  ].freeze
26
26
 
27
- # Create an agent from this template for a user
28
- def create_agent_for(user, name: nil)
29
- agent = user.agents.build(
30
- name: name || self.name,
27
+ # Build (unsaved) an agent from this template inside +relation+: a host
28
+ # user's agents association, or the engine's owner scope
29
+ # (Api::BaseController#owner_agents). The relation decides the owner —
30
+ # including no owner at all in a single-user install, where the old
31
+ # `user.agents.build` had no user to call it on and raised.
32
+ def build_agent_in(relation, name: nil)
33
+ relation.build(
34
+ name: name.presence || self.name,
31
35
  description: description,
32
36
  provider: provider,
33
37
  model: model,
@@ -40,6 +44,12 @@ module ActionAgent
40
44
  model_config: model_config,
41
45
  status: :draft
42
46
  )
47
+ end
48
+
49
+ # Create an agent from this template for a host user with an `agents`
50
+ # association.
51
+ def create_agent_for(user, name: nil)
52
+ agent = build_agent_in(user.agents, name: name)
43
53
 
44
54
  if agent.save
45
55
  increment!(:usage_count)
@@ -158,12 +168,17 @@ module ActionAgent
158
168
  appearance: { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser" },
159
169
  instruction_sets: [],
160
170
  tools: %w[playwright],
161
- mcp_servers: {
162
- playwright: {
171
+ # An array of server entries, which is the shape agents.mcp_servers
172
+ # takes everywhere else (the builder's strong params permit an
173
+ # array). The old top-level Hash was copied onto agents verbatim and
174
+ # crashed ToolDiscovery for the whole workspace.
175
+ mcp_servers: [
176
+ {
177
+ name: "playwright",
163
178
  command: "npx",
164
179
  args: [ "-y", "@anthropic/mcp-server-playwright" ]
165
180
  }
166
- },
181
+ ],
167
182
  model_config: { temperature: 0.2, max_tokens: 4096 },
168
183
  instructions: "You are a browser automation assistant using Playwright MCP.\n\nAvailable actions:\n- browser_navigate: Go to a URL\n- browser_snapshot: Get the accessibility tree\n- browser_click: Click on an element\n- browser_type: Type text into an input\n- browser_take_screenshot: Capture the page\n- browser_wait_for: Wait for text or element\n\nGuidelines:\n1. Always take a snapshot first to understand the page\n2. Use element refs from snapshots for interactions\n3. Wait for page loads before taking actions\n4. Handle errors gracefully\n5. Limit yourself to 10 steps maximum\n\nAlways describe what you see and what actions you're taking.",
169
184
  icon: "🎭",
@@ -13,6 +13,7 @@ module ActionAgent
13
13
  class Evaluation < ApplicationRecord
14
14
  belongs_to :agent
15
15
  has_many :evaluation_runs, dependent: :destroy
16
+ has_many :scenarios, class_name: "EvaluationScenario", dependent: :destroy
16
17
 
17
18
  # judge_defined: the judge model authors the KPI criteria itself from the
18
19
  # agent's instructions + sample interactions on the first run, then scores
@@ -47,8 +48,66 @@ module ActionAgent
47
48
  Array(config["compare_models"]).map(&:to_s).reject(&:blank?)
48
49
  end
49
50
 
50
- def run!
51
- EvaluationRunnerService.call(self)
51
+ # A scenario evaluation replays its own prompts rather than sampling the
52
+ # agent's recorded generations.
53
+ def scenario_suite?
54
+ scenarios.any?
55
+ end
56
+
57
+ def scenario_groups
58
+ # The index preloads scenarios for a page of evaluations; read the
59
+ # loaded association there rather than querying once per suite.
60
+ return scenarios.filter_map { |scenario| scenario.group.presence }.uniq.sort if scenarios.loaded?
61
+
62
+ scenarios.where.not(group: [ nil, "" ]).distinct.order(:group).pluck(:group)
63
+ end
64
+
65
+ # Runs the evaluation. `selection` narrows a scenario evaluation to some of
66
+ # its scenarios (`scenario_ids`, `keys`, `group`) and/or to specific
67
+ # `models`; it is ignored by a generation-sampling evaluation.
68
+ def run!(run: nil, **selection)
69
+ # A run created ahead of time (run_later!) is the scenario runner's even
70
+ # if the suite has since lost its scenarios: it fails that run with
71
+ # "No scenarios selected" rather than leaving it pending forever.
72
+ if run || scenario_suite?
73
+ ScenarioEvaluationRunner.call(self, selection: selection, run: run)
74
+ else
75
+ EvaluationRunnerService.call(self)
76
+ end
77
+ end
78
+
79
+ # Creates the run now and executes it in the background, so a suite of
80
+ # many scenarios under several models does not have to finish inside one
81
+ # request. Returns the pending EvaluationRun.
82
+ def run_later!(**selection)
83
+ run = evaluation_runs.create!(status: :pending, selection: selection.deep_stringify_keys)
84
+ EvaluationRunJob.perform_later(id, run.id, selection.deep_stringify_keys)
85
+ run
86
+ end
87
+
88
+ # Replaces the suite with the scenarios described by +attributes+ (the
89
+ # ActiveAgent::Evals::ScenarioParser output). Keys already in the suite keep their records, so
90
+ # earlier runs' results still resolve to their scenario, and keep their
91
+ # enabled flag unless the attributes set it (a paste cannot).
92
+ def replace_scenarios!(attributes)
93
+ transaction do
94
+ keep = attributes.map { |attrs| attrs["key"] }
95
+ scenarios.where.not(key: keep).destroy_all
96
+
97
+ attributes.each_with_index do |attrs, index|
98
+ scenario = scenarios.find_or_initialize_by(key: attrs["key"])
99
+ scenario.assign_attributes(
100
+ prompt: attrs["prompt"],
101
+ group: attrs["group"],
102
+ notes: attrs["notes"],
103
+ expectations: attrs["expectations"] || {},
104
+ position: attrs.fetch("position", index),
105
+ enabled: attrs.fetch("enabled") { scenario.new_record? || scenario.enabled }
106
+ )
107
+ scenario.save!
108
+ end
109
+ end
110
+ scenarios.reload
52
111
  end
53
112
 
54
113
  def llm_criteria
@@ -60,8 +119,9 @@ module ActionAgent
60
119
  def validate_criteria
61
120
  if criteria.blank?
62
121
  # judge_defined evaluations start empty — the judge authors the KPIs
63
- # on the first run.
64
- errors.add(:criteria, "must include at least one criterion") unless judge_defined?
122
+ # on the first run — and a scenario suite is scored by its scenarios'
123
+ # own expectations even with no criteria.
124
+ errors.add(:criteria, "must include at least one criterion") unless judge_defined? || scenarios.any?
65
125
  return
66
126
  end
67
127
 
@@ -2,19 +2,207 @@
2
2
 
3
3
  module ActionAgent
4
4
  # One execution of an Evaluation over a sample of the agent's generations.
5
- # scores: { criterion_key => { "score", "min", "max", "passed", "total" } }
5
+ # scores: { criterion_key => { "score", "min", "max", "passed", "total" } },
6
+ # except on a comparison run, where each criterion is a cohort map of
7
+ # model => stats and "_"-prefixed metadata keys sit alongside the criteria.
8
+ # See #average_score, which is what has to tolerate both shapes.
6
9
  class EvaluationRun < ApplicationRecord
7
10
  belongs_to :evaluation
11
+ has_many :scenario_results, class_name: "EvaluationScenarioResult", dependent: :destroy
8
12
 
9
13
  enum :status, { pending: 0, running: 1, complete: 2, failed: 3 }
10
14
 
11
15
  scope :recent, -> { order(created_at: :desc) }
12
16
 
17
+ # Which scenarios and models a scenario run covered; empty for a
18
+ # generation-sampling run.
19
+ def selection
20
+ value = super
21
+ value.is_a?(Hash) ? value : {}
22
+ end
23
+
24
+ # The candidate models a scenario run compared, in the order they were
25
+ # requested; empty for a generation-sampling run.
26
+ def models
27
+ Array(scores&.dig("_models")&.keys)
28
+ end
29
+
30
+ def report_metadata
31
+ value = scores&.dig("_metadata")
32
+ value.is_a?(Hash) ? value : {}
33
+ end
34
+
35
+ # The label ActiveAgent::Evals::Report gives a verdict it ranked by pass
36
+ # rate itself, for a comparison no judge was available to rule on. Read
37
+ # from the framework rather than restated: the report reads it back when
38
+ # it names the judge, so the two have to agree on the string.
39
+ PASS_RATE_JUDGE = ActiveAgent::Evals::Report::PASS_RATE_JUDGE
40
+
41
+ # The verdict a comparison run recorded — the judge's pick and rationale
42
+ # when a judge wrote it, the framework's pass-rate ranking otherwise —
43
+ # as `{ "winner", "rationale", "judge" }`; nil for a single-model or
44
+ # generation-sampling run.
45
+ def recorded_verdict
46
+ verdict = scores&.dig("_verdict")
47
+ verdict.to_h.stringify_keys.presence if verdict.is_a?(Hash)
48
+ end
49
+
50
+ # How the report names the judge, the way the suite panel does: the
51
+ # judge the recorded verdict names — unless that is only the pass-rate
52
+ # ranking — else the evaluation's judge model. nil when neither is set,
53
+ # which the report reads as "rules".
54
+ def judge_label
55
+ return scores["_judge_label"] if scores&.key?("_judge_label")
56
+
57
+ recorded = recorded_verdict&.dig("judge").to_s
58
+ return recorded if recorded.present? && recorded != PASS_RATE_JUDGE
59
+
60
+ evaluation.judge_model.presence
61
+ end
62
+
63
+ # scores is not uniformly { criterion => stats }: a comparison run also
64
+ # records "_"-prefixed metadata (EvaluationRunnerService writes
65
+ # "_missing_models" as an Array and "_verdict"), and each of its criteria
66
+ # is a cohort map of model => stats rather than a single stats hash.
67
+ # Only real criterion scores are averaged; anything else is ignored
68
+ # rather than raising and taking the whole Evaluations page down.
13
69
  def average_score
14
- values = scores.values.map { |s| s["score"] }.compact
70
+ values = (scores || {}).reject { |key, _| key.to_s.start_with?("_") }.filter_map do |_key, value|
71
+ criterion_score(value) if value.is_a?(Hash)
72
+ end
15
73
  return nil if values.empty?
16
74
 
17
75
  (values.sum.to_f / values.size).round(3)
18
76
  end
77
+
78
+ # Aggregate usage over the run's scenario results, for display after a
79
+ # run: estimated cost, token totals, summed model time, and the run's
80
+ # wall-clock runtime. Returns nil for a generation-sampling run, which
81
+ # replays nothing itself.
82
+ def usage
83
+ totals = scenario_results.pick(
84
+ Arel.sql("COUNT(*)"), Arel.sql("SUM(cost)"), Arel.sql("SUM(input_tokens)"),
85
+ Arel.sql("SUM(output_tokens)"), Arel.sql("SUM(duration_ms)")
86
+ )
87
+ replays = totals&.first.to_i
88
+ return nil if replays.zero?
89
+
90
+ {
91
+ replays: replays,
92
+ cost: totals[1]&.to_f,
93
+ input_tokens: totals[2].to_i,
94
+ output_tokens: totals[3].to_i,
95
+ model_time_ms: totals[4].to_i,
96
+ runtime_ms: completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
97
+ }
98
+ end
99
+
100
+ # Route templates for the report's fix item actions, relative to the
101
+ # dashboard mount: `%{key}` is filled in per MCP server by the report.
102
+ # The JSON API leaves `mount` empty — the React app resolves paths
103
+ # against the mount itself (dashboardPath) — while the standalone HTML
104
+ # report page is served outside the app and needs the absolute path.
105
+ def report_links(mount: "")
106
+ base = mount.to_s.chomp("/")
107
+
108
+ {
109
+ "mcp" => "#{base}/mcp/%{key}",
110
+ "tools" => "#{base}/tools",
111
+ "instructions" => "#{base}/agents/#{evaluation.agent_id}/edit"
112
+ }
113
+ end
114
+
115
+ # What to fix, from the framework's Report: one item per fault plus one
116
+ # per instruction change the judge proposed, each naming the tools
117
+ # involved, the MCP server that serves them and whether this run's
118
+ # agent has it enabled (EvaluationToolResolver), and the dashboard
119
+ # action that addresses it. Empty for a generation-sampling run.
120
+ def fix_items(links: report_links)
121
+ to_report(links: links).fix_items
122
+ end
123
+
124
+ # Rebuilds the framework's Report from this run's persisted results, so
125
+ # the dashboard serves the same self-contained report page a CLI run
126
+ # writes with Report#to_html. The run's recorded verdict and judge go
127
+ # with it: the report is not to re-rank the rebuilt results by pass
128
+ # rate and show a different judge's pick, verdict or `judged by` than
129
+ # the suite panel does. Raises ActiveRecord::RecordNotFound via the
130
+ # caller for a run of a generation-sampling evaluation, which has no
131
+ # scenario results to report on.
132
+ def to_report(links: report_links)
133
+ rows = scenario_results.includes(:scenario).sort_by do |row|
134
+ [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model ]
135
+ end
136
+ selected = selected_specs
137
+ specs = {}
138
+ results = rows.map do |row|
139
+ spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
140
+ label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
141
+ )
142
+ ActiveAgent::Evals::Result.new(
143
+ scenario: ActiveAgent::Evals::Scenario.from_hash(row.evaluated_scenario),
144
+ spec: spec,
145
+ replay: ActiveAgent::Evals::Replay.new(
146
+ answer: row.output, tool_calls: Array(row.tool_calls), duration_ms: row.duration_ms,
147
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens,
148
+ cost: row.cost&.to_f, error: row.error_message, metadata: row.replay_metadata
149
+ ),
150
+ scores: row.scores.to_h, score: row.score, status: row.status,
151
+ diagnosis: row.evaluation_diagnosis.presence
152
+ )
153
+ end
154
+
155
+ ActiveAgent::Evals::Report.new(
156
+ results: results,
157
+ models: (selected.values & specs.values) + (specs.values - selected.values),
158
+ metadata: {
159
+ "evaluation" => evaluation.name,
160
+ "agent" => evaluation.agent&.name,
161
+ "run" => id,
162
+ "finished" => completed_at&.iso8601
163
+ }.compact.merge(report_metadata),
164
+ verdict: recorded_verdict,
165
+ judge_label: judge_label,
166
+ tool_resolver: EvaluationToolResolver.new(evaluation.agent),
167
+ agent_name: evaluation.agent&.name,
168
+ links: links
169
+ )
170
+ end
171
+
172
+ private
173
+
174
+ ModelSpec = ActiveAgent::Evals::ModelSpec
175
+ private_constant :ModelSpec
176
+
177
+ # The candidate specs the run was asked to compare, keyed by
178
+ # [provider, model] in the order requested. ScenarioEvaluationRunner
179
+ # persists each ModelSpec#to_h in `selection`, and it is that label —
180
+ # the string the user typed, e.g. "gpt-5-mini" — that keys the run's
181
+ # `_models` and names the verdict's winner, so the rebuilt report has
182
+ # to reuse it rather than relabel every model provider/model.
183
+ def selected_specs
184
+ Array(selection["models"]).filter_map do |entry|
185
+ next unless entry.is_a?(Hash)
186
+
187
+ entry = entry.stringify_keys
188
+ next if entry["model"].blank?
189
+
190
+ ModelSpec.new(label: entry["label"].presence || entry["model"], provider: entry["provider"].to_s, model: entry["model"])
191
+ end.index_by { |spec| [ spec.provider, spec.model ] }
192
+ end
193
+
194
+ # A criterion is either scored directly ({ "score" => 0.8, ... }) or, on a
195
+ # comparison run, a map of model => stats; that cohort's mean is the
196
+ # criterion's headline score. Skipped criteria carry no score at all.
197
+ def criterion_score(value)
198
+ return value["score"] if value["score"].is_a?(Numeric)
199
+
200
+ cohort = value.each_value.filter_map do |stats|
201
+ stats["score"] if stats.is_a?(Hash) && stats["score"].is_a?(Numeric)
202
+ end
203
+ return nil if cohort.empty?
204
+
205
+ cohort.sum.to_f / cohort.size
206
+ end
19
207
  end
20
208
  end