actionagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +75 -62
  4. data/app/controllers/action_agent/api/agents_controller.rb +15 -6
  5. data/app/controllers/action_agent/api/base_controller.rb +3 -2
  6. data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
  7. data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
  8. data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
  9. data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
  10. data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
  11. data/app/controllers/action_agent/traces_controller.rb +3 -2
  12. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
  13. data/app/jobs/action_agent/agent_execution_job.rb +26 -6
  14. data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
  15. data/app/models/action_agent/agent_run.rb +25 -6
  16. data/app/models/action_agent/catalog_scenario.rb +8 -2
  17. data/app/models/action_agent/evaluation_run.rb +54 -3
  18. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  19. data/app/models/action_agent/mcp_server_config.rb +273 -0
  20. data/app/models/action_agent/model_pricing.rb +135 -24
  21. data/app/models/action_agent/scenario_product.rb +24 -0
  22. data/app/models/action_agent/scenario_set.rb +9 -0
  23. data/app/models/action_agent/telemetry_trace.rb +35 -14
  24. data/app/queries/action_agent/agent_executions.rb +4 -1
  25. data/app/queries/action_agent/metrics_report.rb +1 -1
  26. data/app/serializers/action_agent/evaluation_serializer.rb +6 -2
  27. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  28. data/app/services/action_agent/agent_execution_service.rb +4 -2
  29. data/app/services/action_agent/agent_scorecard.rb +16 -12
  30. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  31. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  32. data/app/services/action_agent/evaluation_report_import.rb +63 -4
  33. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  34. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  35. data/app/services/action_agent/mcp_client.rb +36 -4
  36. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  37. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  38. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  39. data/app/services/action_agent/scenario_catalog_import.rb +7 -0
  40. data/app/services/action_agent/scenario_evaluation_runner.rb +135 -18
  41. data/app/services/action_agent/tool_discovery.rb +5 -1
  42. data/config/routes.rb +6 -0
  43. data/lib/action_agent/engine.rb +10 -0
  44. data/lib/action_agent/version.rb +1 -1
  45. data/lib/action_agent.rb +17 -0
  46. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  47. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  48. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  49. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  50. data/lib/generators/action_agent/templates/migrations/034_add_catalog_product_criteria.rb.erb +24 -0
  51. data/lib/generators/action_agent/templates/migrations/035_add_catalog_pass_bars.rb.erb +28 -0
  52. metadata +10 -2
@@ -279,10 +279,10 @@ module ActionAgent
279
279
  # ones the caller may see as well. Intersected by id rather than with
280
280
  # merge: both relations constrain the same owner column, and merge
281
281
  # lets the second condition replace the first instead of adding to it.
282
+ # The servers the owner added on the MCP Services page are offered the
283
+ # same way.
282
284
  def tool_roster
283
- runtimes = SandboxSession.runtime_server_listings(
284
- SandboxSession.for_owner(@agent.owner).where(id: owned(SandboxSession).select(:id))
285
- )
285
+ runtimes = owner_server_listings(@agent.owner)
286
286
 
287
287
  render json: AgentToolRoster.new(
288
288
  agent: @agent,
@@ -303,9 +303,7 @@ module ActionAgent
303
303
  # an agent's history: which services its agents already enable.
304
304
  def new_tool_roster
305
305
  agent = owner_agents.build
306
- runtimes = SandboxSession.runtime_server_listings(
307
- SandboxSession.for_owner(agent.owner).where(id: owned(SandboxSession).select(:id))
308
- )
306
+ runtimes = owner_server_listings(agent.owner)
309
307
 
310
308
  render json: AgentToolRoster.new(
311
309
  agent: agent,
@@ -444,6 +442,17 @@ module ActionAgent
444
442
 
445
443
  private
446
444
 
445
+ # The checkout runtimes and added MCP servers an agent of +owner+ can be
446
+ # given, as MCP server listings: +owner+'s, and of those only the ones
447
+ # the caller may see as well.
448
+ def owner_server_listings(owner)
449
+ SandboxSession.runtime_server_listings(
450
+ SandboxSession.for_owner(owner).where(id: owned(SandboxSession).select(:id))
451
+ ) + MCPServerConfig.listings(
452
+ MCPServerConfig.for_owner(owner).where(id: owned(MCPServerConfig).select(:id))
453
+ )
454
+ end
455
+
447
456
  # Observed agents were discovered from reported telemetry; the platform
448
457
  # has no configuration to run them with (a placeholder model, no
449
458
  # instructions), so executing one only manufactured a failed run that
@@ -114,8 +114,8 @@ module ActionAgent
114
114
 
115
115
  # Returns the credentials the caller owns, for masking with
116
116
  # SecretScrubber: provider keys, GitHub tokens, checkout sandboxes'
117
- # runtime tokens, dashboard API keys (newest first) and the owner's
118
- # telemetry key. Raises what a lookup raises, such as a credential that
117
+ # runtime tokens, dashboard API keys (newest first), the header values
118
+ # of the owner's own MCP servers and the owner's telemetry key. Raises what a lookup raises, such as a credential that
119
119
  # cannot be decrypted, so each caller decides whether to go on without
120
120
  # the list.
121
121
  # @return [Array<String>]
@@ -125,6 +125,7 @@ module ActionAgent
125
125
  *owned(GithubConnection).limit(OWNER_CREDENTIAL_LIMIT).pluck(:access_token),
126
126
  *owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:runtime_mcp_token),
127
127
  *owned(ApiKey).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:token),
128
+ *owned(MCPServerConfig).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).flat_map(&:secret_values),
128
129
  current_owner.try(:telemetry_api_key)
129
130
  ].compact
130
131
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The caller's own remote MCP servers (MCPServerConfig), managed from the
6
+ # MCP Services page and enabled on agents as "custom:<id>".
7
+ #
8
+ # Every query goes through `owned`, so a caller reads, changes, tests and
9
+ # deletes only their owner's configurations; another owner's id is a 404.
10
+ # Header values are write-only: responses carry each header's name and a
11
+ # masked value. On update, a header sent with a blank value keeps the
12
+ # value stored under that name, and a header left out is removed; leaving
13
+ # +headers+ out of the request keeps them all.
14
+ #
15
+ # Writes and tests ask permission_checker for :manage_credentials, since a
16
+ # configuration holds credentials and decides what the owner's agents
17
+ # call. Reading the list asks nothing.
18
+ class MCPServerConfigsController < BaseController
19
+ before_action :require_owner!
20
+ before_action :set_config, only: %i[update destroy test]
21
+
22
+ # GET /api/mcp_server_configs
23
+ def index
24
+ configs = owned(MCPServerConfig).order(:name, :id)
25
+
26
+ render json: {
27
+ mcp_server_configs: configs.map(&:as_api_json),
28
+ allow_private_hosts: MCPHostGuard.allow_private?,
29
+ can_manage: can_manage?
30
+ }
31
+ end
32
+
33
+ # POST /api/mcp_server_configs
34
+ def create
35
+ config = owned(MCPServerConfig).new
36
+ assign_attributes(config)
37
+ return unless authorize_action!(:manage_credentials, config)
38
+
39
+ config.save!
40
+ render json: { mcp_server_config: config.as_api_json }, status: :created
41
+ end
42
+
43
+ # PATCH /api/mcp_server_configs/:id
44
+ def update
45
+ return unless authorize_action!(:manage_credentials, @config)
46
+
47
+ assign_attributes(@config)
48
+ # A new URL has not been tested; what an earlier test found about the
49
+ # old one no longer describes it.
50
+ reset_test_result(@config) if @config.url_changed?
51
+ @config.save!
52
+ render json: { mcp_server_config: @config.as_api_json }
53
+ end
54
+
55
+ # DELETE /api/mcp_server_configs/:id
56
+ #
57
+ # Agents that enable the configuration keep naming its key, which then
58
+ # resolves to nothing: the Tools tab lists it as unknown so it can be
59
+ # switched off.
60
+ def destroy
61
+ return unless authorize_action!(:manage_credentials, @config)
62
+
63
+ @config.destroy!
64
+ head :no_content
65
+ end
66
+
67
+ # POST /api/mcp_server_configs/:id/test
68
+ #
69
+ # Runs tools/list against the saved configuration and records the
70
+ # outcome, the discovered tool names included. Answers 200 either way:
71
+ # a server that does not answer is a result to show, not a failed
72
+ # request.
73
+ def test
74
+ return unless authorize_action!(:manage_credentials, @config)
75
+
76
+ result = @config.test_connection!
77
+ render json: result.merge(mcp_server_config: @config.as_api_json)
78
+ end
79
+
80
+ private
81
+
82
+ def set_config
83
+ @config = owned(MCPServerConfig).find(params[:id])
84
+ end
85
+
86
+ def can_manage?
87
+ ActionAgent.permitted?(current_user, :manage_credentials, owned(MCPServerConfig).new)
88
+ end
89
+
90
+ def assign_attributes(config)
91
+ attributes = params.require(:mcp_server_config).permit(:name, :description, :url, :enabled)
92
+ config.assign_attributes(attributes)
93
+ config.assign_headers(header_params) if params[:mcp_server_config].key?(:headers)
94
+ end
95
+
96
+ # The submitted headers as { name, value } pairs. Accepts a list, or a
97
+ # Hash of name => value.
98
+ def header_params
99
+ submitted = params[:mcp_server_config][:headers]
100
+ return [] if submitted.blank?
101
+
102
+ if submitted.is_a?(Array)
103
+ submitted.map { |header| header.respond_to?(:permit) ? header.permit(:name, :value).to_h : {} }
104
+ else
105
+ submitted.permit!.to_h.map { |name, value| { "name" => name, "value" => value } }
106
+ end
107
+ end
108
+
109
+ def reset_test_result(config)
110
+ config.assign_attributes(last_tested_at: nil, last_test_status: nil, last_test_error: nil, tool_names: [])
111
+ end
112
+ end
113
+ end
114
+ end
@@ -10,7 +10,8 @@ module ActionAgent
10
10
  # its configuration, and the default catalog (MCPCatalog). An install
11
11
  # therefore sees both what it is already using and what it could turn on.
12
12
  # The caller's live checkout sandbox runtimes are listed with the catalog,
13
- # so the one started from Settings -> Integrations shows up here too.
13
+ # so the one started from Settings -> Integrations shows up here too, and
14
+ # so are the servers the caller added (MCPServerConfigsController).
14
15
  class MCPServersController < BaseController
15
16
  before_action :require_owner!
16
17
  # Launching provisions a sandbox and runs a server in it, so it answers
@@ -110,14 +111,16 @@ module ActionAgent
110
111
 
111
112
  private
112
113
 
113
- # Runtimes are the caller's own: a listing carries no token, but which
114
- # repositories another owner has running is still theirs to know.
114
+ # Runtimes and added servers are the caller's own: a listing carries no
115
+ # token or header, but which repositories another owner has running and
116
+ # which servers they call are still theirs to know.
115
117
  def discovery
116
118
  ToolDiscovery.new(
117
119
  traces: owned_traces,
118
120
  agents: owner_agents,
119
121
  hours: window_hours,
120
- runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession))
122
+ runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession)) +
123
+ MCPServerConfig.listings(owned(MCPServerConfig))
121
124
  )
122
125
  end
123
126
 
@@ -181,17 +181,18 @@ module ActionAgent
181
181
  end
182
182
 
183
183
  # Estimated spend for the window, total and per agent class. The model
184
- # lives inside the spans JSON (first llm span).
184
+ # and the provider it ran on live inside the spans JSON (first llm
185
+ # span); the rate is the model's under that provider.
185
186
  def cost_statistics(scope)
186
- rows = ActionAgent.trace_model.pluck_with_llm_model(
187
+ rows = ActionAgent.trace_model.pluck_with_llm_model_and_provider(
187
188
  scope, :agent_class, :total_input_tokens, :total_output_tokens
188
189
  )
189
190
 
190
191
  by_agent = Hash.new(0.0)
191
192
  total = 0.0
192
193
 
193
- rows.each do |model, agent_class, input_tokens, output_tokens|
194
- cost = ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
194
+ rows.each do |model, provider, agent_class, input_tokens, output_tokens|
195
+ cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input_tokens, output_tokens: output_tokens)
195
196
  next unless cost
196
197
 
197
198
  total += cost
@@ -94,7 +94,7 @@ module ActionAgent
94
94
 
95
95
  host = params[:credential].presence || stored&.credential || platform_host(provider)
96
96
  if host.blank?
97
- return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured" }
97
+ return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured", cloud: nil, rates: {} }
98
98
  end
99
99
 
100
100
  render json: OllamaHostProbe.call(host: host, api_key: probe_api_key(stored, host)).to_h
@@ -9,10 +9,12 @@ module ActionAgent
9
9
  # side, so the UI can't drift stale). Ollama is queried live from the
10
10
  # caller's effective host (see ProviderCredentials: their personal key,
11
11
  # the host resolver's, the account's, falling back to the platform
12
- # config) so locally pulled models appear; OpenRouter is queried from its
13
- # public catalog, and Anthropic from its Models API with the caller's
14
- # effective key. Live lookups fall back to the curated list on any
15
- # failure, and when the caller's credentials cannot be resolved.
12
+ # config) so locally pulled models appear — or, when the host is Ollama
13
+ # Cloud, the cloud's models, which the response marks (`cloud`) and
14
+ # prices (`rates`, per model a price list names); OpenRouter is queried
15
+ # from its public catalog, and Anthropic from its Models API with the
16
+ # caller's effective key. Live lookups fall back to the curated list on
17
+ # any failure, and when the caller's credentials cannot be resolved.
16
18
  #
17
19
  # When the host app loads RubyLLM, the chat models its registry lists for
18
20
  # the provider that take and return text follow: RubyLLM's bundled
@@ -51,7 +53,9 @@ module ActionAgent
51
53
  source = "curated"
52
54
  end
53
55
 
54
- render json: { provider: provider, models: (models + registry_models(provider)).uniq, source: source }
56
+ payload = { provider: provider, models: (models + registry_models(provider)).uniq, source: source }
57
+ payload.merge!(ollama_detail) if provider == "ollama"
58
+ render json: payload
55
59
  end
56
60
 
57
61
  private
@@ -101,9 +105,17 @@ module ActionAgent
101
105
  return nil
102
106
  end
103
107
 
108
+ @ollama_probe = result
104
109
  [ result.models, "live" ] if result.models.any?
105
110
  end
106
111
 
112
+ # What the live lookup learned about the caller's Ollama host: whether
113
+ # it is Ollama Cloud (nil when no host answered), and the rate of
114
+ # every listed model a price list names, in $ per million tokens.
115
+ def ollama_detail
116
+ { cloud: @ollama_probe&.cloud, rates: @ollama_probe&.rates || {} }
117
+ end
118
+
107
119
  # Queries the Anthropic Models API with the caller's effective key
108
120
  # (newest first, as returned by the API) so new model releases appear
109
121
  # without a deploy. The platform's own key is never used here.
@@ -4,7 +4,7 @@ module ActionAgent
4
4
  # Controller for viewing telemetry traces.
5
5
  #
6
6
  # Provides:
7
- # - List view with filtering and pagination
7
+ # - Bounded list of recent traces with filtering (no pagination)
8
8
  # - Detail view with span timeline
9
9
  # - Metrics overview
10
10
  # - Live updates via Turbo Streams
@@ -76,7 +76,8 @@ module ActionAgent
76
76
  )
77
77
  end
78
78
 
79
- traces.limit(params[:limit] || 50)
79
+ limit = Integer(params[:limit], 10, exception: false) || 50
80
+ traces.limit(limit.clamp(1, 1000))
80
81
  end
81
82
 
82
83
  def calculate_metrics
@@ -842,7 +842,7 @@ module ActionAgent
842
842
  change = comparison_change(before, after)
843
843
  next if change == "unchanged_pass"
844
844
 
845
- { scenario_key: key[0], model: key[1], change: change, before: before&.status, after: after&.status,
845
+ { scenario_key: key[0], model: key[1], trial: key[2], change: change, before: before&.status, after: after&.status,
846
846
  fault: after&.fault, result_id: after&.id }
847
847
  end
848
848
 
@@ -1012,8 +1012,10 @@ module ActionAgent
1012
1012
  end
1013
1013
 
1014
1014
  # The run's results keyed by [scenario key, model].
1015
+ # Results by scenario key, model and trial: a run that replayed each
1016
+ # scenario several times compares trial by trial.
1015
1017
  def comparison_rows(run)
1016
- run.scenario_results.includes(:scenario).index_by { |result| [ result.evaluated_scenario["key"], result.model ] }
1018
+ run.scenario_results.includes(:scenario).index_by { |result| [ result.evaluated_scenario["key"], result.model, result.trial ] }
1017
1019
  end
1018
1020
 
1019
1021
  # One of: removed, added_pass, added_fail, unchanged_pass, regressed,
@@ -6,13 +6,12 @@ module ActionAgent
6
6
 
7
7
  def perform(run_id)
8
8
  run = AgentRun.find(run_id)
9
- # Anything already finished — complete, failed or cancelled — is never
10
- # executed again, and neither is a run waiting for input, which only
11
- # AgentResumeJob continues. A retry of a failed run would re-run the
12
- # generation.
13
- return if run.finished? || run.awaiting_input?
9
+ # Only a pending run is executed. A finished run (complete, failed or
10
+ # cancelled) is never executed again, a run waiting for input only
11
+ # AgentResumeJob continues, and a running run already has a worker. A
12
+ # retry of a failed run would re-run the generation.
13
+ return unless claim_pending(run)
14
14
 
15
- run.update!(status: :running, started_at: Time.current)
16
15
  run.add_log("Starting execution", level: :info)
17
16
 
18
17
  begin
@@ -38,6 +37,27 @@ module ActionAgent
38
37
 
39
38
  private
40
39
 
40
+ # Moves the run from pending to running in one conditional update, as
41
+ # CodeSessionJob#start claims a session, and returns whether this job is
42
+ # the one that did. The write checks the status itself, so of two
43
+ # workers that loaded the run as pending only the first to write
44
+ # executes it, and the other starts no generation, on every database. A
45
+ # read under the row lock followed by a write would be refused on SQLite
46
+ # with Rails 7.2, whose deferred transactions cannot write once another
47
+ # writer has committed. Nothing is held over the generation, so a cancel
48
+ # never waits for it. The update skips callbacks, so the status change is
49
+ # broadcast here.
50
+ def claim_pending(run)
51
+ now = Time.current
52
+ claimed = AgentRun.where(id: run.id, status: AgentRun.statuses[:pending])
53
+ .update_all(status: AgentRun.statuses[:running], started_at: now, updated_at: now)
54
+ return false if claimed.zero?
55
+
56
+ run.reload
57
+ run.broadcast_update
58
+ true
59
+ end
60
+
41
61
  def execute_agent(agent_record, run)
42
62
  AgentExecutionService.call(agent_record, run)
43
63
  end
@@ -17,11 +17,12 @@ module ActionAgent
17
17
  return finish_without_result(run_id, selection) unless row
18
18
 
19
19
  run = row.evaluation_run
20
+ runner = ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run)
20
21
  begin
21
- ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).judge_result!(row)
22
+ runner.judge_result!(row)
22
23
  rescue StandardError => e
23
24
  Rails.logger.warn("[ActionAgent] evaluation run #{run.id}: scoring result #{result_id} failed: #{e.class}: #{e.message}")
24
- ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).fail_judging!(row.reload, e)
25
+ runner.fail_judging!(row.reload, e)
25
26
  end
26
27
  end
27
28
 
@@ -313,18 +313,37 @@ module ActionAgent
313
313
  LiveUpdates.broadcast("agent_runs_#{agent_id}", type: "update", id: id, status: status)
314
314
  end
315
315
 
316
+ # Statuses a cancel moves to `cancelled`. A finished run keeps its end.
317
+ CANCELLABLE_STATUSES = %i[pending running awaiting_input].freeze
318
+
316
319
  # Cancels a run that is in progress or waiting for input, and every
317
- # request for input it still has pending.
320
+ # request for input it still has pending. Returns whether this call
321
+ # cancelled it.
322
+ #
323
+ # The status is checked by the write itself, as AgentExecutionJob claims
324
+ # a pending run, because this copy of the run may be older than the
325
+ # database's: the request's, or a caller's that has held it since it
326
+ # executed. A run that finished in the meantime is left as it finished,
327
+ # and this copy is reloaded to show how. The update skips callbacks, so
328
+ # the cancel is broadcast here, once.
318
329
  #
319
330
  # @param reason [String] recorded as the run's error message
331
+ # @return [Boolean]
320
332
  def cancel!(reason = "Cancelled by user")
321
- return unless in_progress? || awaiting_input?
333
+ return false unless in_progress? || awaiting_input?
322
334
 
323
- transaction do
324
- update!(status: :cancelled, completed_at: Time.current, error_message: reason)
325
- input_requests.pending.update_all(status: InputRequest.statuses[:cancelled], updated_at: Time.current)
335
+ now = Time.current
336
+ cancelled = transaction do
337
+ changed = self.class.where(id: id, status: self.class.statuses.values_at(*CANCELLABLE_STATUSES))
338
+ .update_all(status: self.class.statuses[:cancelled], completed_at: now, error_message: reason, updated_at: now)
339
+ next false if changed.zero?
340
+
341
+ input_requests.pending.update_all(status: InputRequest.statuses[:cancelled], updated_at: now)
342
+ true
326
343
  end
327
- broadcast_update
344
+ reload
345
+ broadcast_update if cancelled
346
+ cancelled
328
347
  end
329
348
 
330
349
  # Expires the run's overdue requests for input, which fails the run (see
@@ -27,9 +27,15 @@ module ActionAgent
27
27
  value.is_a?(Hash) ? value : {}
28
28
  end
29
29
 
30
+ def metadata
31
+ value = self[:metadata]
32
+ value.is_a?(Hash) ? value : {}
33
+ end
34
+
30
35
  # @return [ActiveAgent::Evals::Scenario]
31
36
  def to_scenario
32
- ActiveAgent::Evals::Scenario.from_hash(to_document.merge("position" => position), group: set.key, group_name: set.name)
37
+ ActiveAgent::Evals::Scenario.from_hash(to_document.merge("position" => position), group: set.key, group_name: set.name,
38
+ metadata: metadata)
33
39
  end
34
40
 
35
41
  def to_document
@@ -41,7 +47,7 @@ module ActionAgent
41
47
  "tags" => tags.presence,
42
48
  "params" => params.presence,
43
49
  "production_only" => (true if production_only?)
44
- }.compact
50
+ }.compact.merge(metadata.deep_stringify_keys.except(*ActiveAgent::Evals::Catalog::SCENARIO_KEYS))
45
51
  end
46
52
 
47
53
  # The attributes Evaluation#replace_scenarios! takes.
@@ -87,6 +87,48 @@ module ActionAgent
87
87
  comparison if comparison.is_a?(Hash) && comparison.present?
88
88
  end
89
89
 
90
+ # The bars this run was gated by (ActiveAgent::Evals::PassThresholds),
91
+ # as the run recorded them when it started or was imported, so a later
92
+ # change to the catalog does not regrade it; nil for a run whose suite
93
+ # declared none, one recorded before runs kept them, and a framework
94
+ # without per-group bars.
95
+ def pass_thresholds
96
+ self.class.pass_thresholds_from(scores&.dig("_pass_thresholds"))
97
+ end
98
+
99
+ # A recorded `pass_thresholds` mapping (Report#to_h's: "threshold",
100
+ # "min_pass_rate", "groups") as PassThresholds, or nil for anything
101
+ # that is not one.
102
+ def self.pass_thresholds_from(value)
103
+ return nil unless value.is_a?(Hash) && defined?(ActiveAgent::Evals::PassThresholds)
104
+
105
+ value = value.stringify_keys
106
+ groups = value["groups"].nil? ? {} : value["groups"]
107
+ return nil unless groups.is_a?(Hash)
108
+
109
+ ActiveAgent::Evals::PassThresholds.new(suite: value.slice(*ActiveAgent::Evals::PassThresholds::SUITE_KEYS), groups: groups)
110
+ rescue ArgumentError
111
+ nil
112
+ end
113
+
114
+ # The gates the run's results met or missed (Report#gates, each
115
+ # `{ "group", "name", "label", "passed", "total", "pass_rate",
116
+ # "min_pass_rate", "gated", "met" }`), as recorded when it completed;
117
+ # nil for a run without bars.
118
+ def gates
119
+ value = scores&.dig("_gates")
120
+ value.select { |gate| gate.is_a?(Hash) } if value.is_a?(Array)
121
+ end
122
+
123
+ # Whether every gate was met, as Report#passed? answers: false when no
124
+ # gate was reached; nil for a run without bars.
125
+ def gates_met
126
+ rows = gates
127
+ return nil if rows.nil?
128
+
129
+ rows.any? && rows.all? { |gate| gate["met"] == true }
130
+ end
131
+
90
132
  # How the report names the judge, the way the suite panel does: the
91
133
  # judge the recorded verdict names — unless that is only the pass-rate
92
134
  # ranking — else the evaluation's judge model. nil when neither is set,
@@ -255,17 +297,19 @@ module ActionAgent
255
297
  # for one the engine judged.
256
298
  #
257
299
  # The engine runs against every activeagent since 1.4, so the report
258
- # kwargs that arrived later are passed only when Report.new takes them.
300
+ # kwargs that arrived later are passed only when Report.new takes them,
301
+ # and a result's trial only when Result carries one.
259
302
  def to_report(links: report_links, estimate: true)
260
303
  # A replay not yet scored (a fanned-out run in progress) is not a
261
304
  # result the report can grade, so it is left out rather than read as
262
305
  # a failure.
263
306
  rows = scenario_results.where.not(status: :pending).includes(:scenario).sort_by do |row|
264
- [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model ]
307
+ [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model, row.trial, row.id ]
265
308
  end
266
309
  breakdown = cost_breakdown if estimate
267
310
  selected = selected_specs
268
311
  specs = {}
312
+ trials = self.class.result_accepts?(:trial)
269
313
  results = rows.map do |row|
270
314
  spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
271
315
  label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
@@ -280,7 +324,8 @@ module ActionAgent
280
324
  metadata: replay_metadata_for(row, breakdown)
281
325
  ),
282
326
  scores: row.scores.to_h, score: row.score, status: row.status,
283
- diagnosis: row.evaluation_diagnosis.presence
327
+ diagnosis: row.evaluation_diagnosis.presence,
328
+ **(trials ? { trial: row.trial } : {})
284
329
  )
285
330
  end
286
331
 
@@ -304,6 +349,7 @@ module ActionAgent
304
349
  kwargs[:judge_usage] = judge_usage_run if judge_usage_run && self.class.report_accepts?(:judge_usage)
305
350
  kwargs[:release] = release_summary if release_summary && self.class.report_accepts?(:release)
306
351
  kwargs[:comparison] = recorded_comparison if recorded_comparison && self.class.report_accepts?(:comparison)
352
+ kwargs[:pass_thresholds] = pass_thresholds if pass_thresholds && self.class.report_accepts?(:pass_thresholds)
307
353
  self.class.report_class.new(**kwargs)
308
354
  end
309
355
 
@@ -317,6 +363,11 @@ module ActionAgent
317
363
  report_class.instance_method(:initialize).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
318
364
  end
319
365
 
366
+ # Whether the installed framework's Result carries +member+.
367
+ def self.result_accepts?(member)
368
+ ActiveAgent::Evals::Result.members.include?(member)
369
+ end
370
+
320
371
  # The release this run scored, as the report names it — `{ "digest",
321
372
  # "revision", "label" }` — or nil for a run pinned to a dashboard edit
322
373
  # or to no version.
@@ -45,10 +45,19 @@ module ActionAgent
45
45
  end
46
46
 
47
47
  # The diagnosis as the dashboard shows it: without the storage keys —
48
- # the replay metadata, the scenario snapshot and the judge usage the
49
- # publishing application reported (served as `judge_usage` instead).
48
+ # the replay metadata, the scenario snapshot, the judge usage the
49
+ # publishing application reported (served as `judge_usage` instead) and
50
+ # the trial number.
50
51
  def evaluation_diagnosis
51
- (diagnosis || {}).except("_replay_metadata", "_scenario_snapshot", "_judge_usage")
52
+ (diagnosis || {}).except("_replay_metadata", "_scenario_snapshot", "_judge_usage", "_trial")
53
+ end
54
+
55
+ # Which replay of the scenario under the model this is, from 1: a
56
+ # published run keeps every replay of a scenario the application ran
57
+ # more than once (EvaluationReportImport). A result with no record is
58
+ # the first.
59
+ def trial
60
+ [ diagnosis&.dig("_trial").to_i, 1 ].max
52
61
  end
53
62
 
54
63
  # A catalog can be refreshed without changing what an earlier run asked