actionagent 1.9.5 → 1.9.7

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 (48) 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 +62 -61
  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/projects_controller.rb +29 -4
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +31 -6
  13. data/app/controllers/action_agent/traces_controller.rb +3 -2
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
  15. data/app/jobs/action_agent/agent_execution_job.rb +26 -6
  16. data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
  17. data/app/models/action_agent/catalog_scenario.rb +8 -2
  18. data/app/models/action_agent/evaluation_run.rb +11 -3
  19. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  20. data/app/models/action_agent/mcp_server_config.rb +273 -0
  21. data/app/models/action_agent/model_pricing.rb +135 -24
  22. data/app/models/action_agent/telemetry_trace.rb +35 -14
  23. data/app/queries/action_agent/agent_executions.rb +4 -1
  24. data/app/queries/action_agent/metrics_report.rb +1 -1
  25. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  26. data/app/services/action_agent/agent_execution_service.rb +4 -2
  27. data/app/services/action_agent/agent_scorecard.rb +16 -12
  28. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  29. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  30. data/app/services/action_agent/evaluation_report_import.rb +14 -2
  31. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  32. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  33. data/app/services/action_agent/mcp_client.rb +36 -4
  34. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  35. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  36. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  37. data/app/services/action_agent/scenario_catalog_import.rb +2 -0
  38. data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
  39. data/app/services/action_agent/tool_discovery.rb +5 -1
  40. data/config/routes.rb +6 -0
  41. data/lib/action_agent/engine.rb +10 -0
  42. data/lib/action_agent/version.rb +1 -1
  43. data/lib/action_agent.rb +17 -0
  44. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  45. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  46. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  47. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  48. metadata +8 -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
@@ -17,6 +17,8 @@ module ActionAgent
17
17
  LOG_TAIL_BYTES = 8 * 1024
18
18
  LOG_PAGE_BYTES = 64 * 1024
19
19
  LOG_MAX_PAGE_BYTES = 1024 * 1024
20
+ # The most of a backend's error the boot status carries (see #boot_report).
21
+ BACKEND_ERROR_BYTES = 1_000
20
22
  UPDATABLE = %i[name default_ref start_url].freeze
21
23
 
22
24
  before_action :require_owner!
@@ -214,18 +216,20 @@ module ActionAgent
214
216
  # The current sandbox's boot, step by step (see
215
217
  # SandboxOrchestrator#boot_status), with the scrubbed tail of the step
216
218
  # that failed or is running, and the modes its browser can start in
217
- # (none when the backend runs no browsers).
219
+ # (none when the backend runs no browsers). When the backend cannot
220
+ # report the boot, `boot` is null and `backend_error` names the backend
221
+ # and the reason (see #boot_report).
218
222
  def boot_status
219
223
  sandbox = @project.current_sandbox_session
220
224
  orchestrator = SandboxOrchestrator.new
221
- status = sandbox && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(sandbox) : nil
222
- status = SecretScrubber.scrub(status.deep_symbolize_keys, @project.scrub_values) if status.is_a?(Hash)
225
+ status, log_tail, backend_error = boot_report(orchestrator, sandbox)
223
226
 
224
227
  render json: {
225
228
  project: @project.summary,
226
229
  boot: status,
227
- log_tail: status && log_tail(orchestrator, sandbox, status),
230
+ log_tail: log_tail,
228
231
  error: sandbox&.failed? ? SecretScrubber.scrub(sandbox.error_summary, @project.scrub_values) : nil,
232
+ backend_error: backend_error,
229
233
  logs: sandbox.present? && orchestrator.supports?(:boot_log),
230
234
  confirmation: @project.local_boot_confirmation(orchestrator),
231
235
  browser_modes: browser_modes(orchestrator)
@@ -535,6 +539,27 @@ module ActionAgent
535
539
  []
536
540
  end
537
541
 
542
+ # The backend's account of the sandbox's boot, scrubbed, with the tail
543
+ # of the step's log that goes with it: [status, log_tail, nil]. When the
544
+ # backend cannot give one (no host configured, the host unreachable, a
545
+ # container that has not written its state yet), [nil, nil, error],
546
+ # where error names the backend and the scrubbed reason. The page then
547
+ # shows the boot as unknown for that reason and keeps following it,
548
+ # instead of failing to load the project (#650). The sandbox's own
549
+ # failure, if it failed, is reported beside it as `error`.
550
+ def boot_report(orchestrator, sandbox)
551
+ return [ nil, nil, nil ] unless sandbox && orchestrator.supports?(:boot_status)
552
+
553
+ status = orchestrator.boot_status(sandbox)
554
+ status = SecretScrubber.scrub(status.deep_symbolize_keys, @project.scrub_values) if status.is_a?(Hash)
555
+ [ status, status && log_tail(orchestrator, sandbox, status), nil ]
556
+ rescue StandardError => e
557
+ Rails.logger.warn("[ActionAgent] the #{orchestrator.backend_name} sandbox backend could not report the boot of " \
558
+ "sandbox #{sandbox.session_id}: #{e.class}: #{e.message}")
559
+ [ nil, nil, { backend: orchestrator.backend_name,
560
+ message: SecretScrubber.scrub(e.message.to_s, @project.scrub_values).truncate(BACKEND_ERROR_BYTES) } ]
561
+ end
562
+
538
563
  # The last LOG_TAIL_BYTES of the step that failed, else of the one
539
564
  # running, else of the last that ran: { step:, text:, truncated: }.
540
565
  def log_tail(orchestrator, sandbox, status)
@@ -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.
@@ -211,18 +211,19 @@ module ActionAgent
211
211
  # GET /api/sandboxes/:session_id/boot
212
212
  # How a checkout's boot went, step by step (see
213
213
  # SandboxOrchestrator#boot_status). `boot` is null when the backend
214
- # reports no steps, or holds nothing for the sandbox.
214
+ # reports no steps, or holds nothing for the sandbox; when the backend
215
+ # cannot report it at all, `backend_error` names the backend and the
216
+ # reason (see #boot_report).
215
217
  def boot
216
218
  orchestrator = SandboxOrchestrator.new
217
- status = @sandbox.app_runtime? && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(@sandbox) : nil
218
- # A project's checkout booted with its secrets, which the backend may
219
- # not know to mask.
220
- status = SecretScrubber.scrub(status, @sandbox.project_scrub_values) if status
221
- render json: {
219
+ status, backend_error = boot_report(orchestrator)
220
+ payload = {
222
221
  boot: status,
223
222
  resumable: !!(status&.dig(:kept) && @sandbox.failed? && !@sandbox.past_expiry? && orchestrator.supports?(:resume_boot)),
224
223
  logs: @sandbox.app_runtime? && orchestrator.supports?(:boot_log)
225
224
  }
225
+ payload[:backend_error] = backend_error if backend_error
226
+ render json: payload
226
227
  end
227
228
 
228
229
  # GET /api/sandboxes/:session_id/boot_log?step=NAME&offset=N&limit=N
@@ -298,6 +299,30 @@ module ActionAgent
298
299
 
299
300
  private
300
301
 
302
+ # The most of a backend's error the boot status carries (see #boot_report).
303
+ BACKEND_ERROR_BYTES = 1_000
304
+
305
+ # The backend's account of the sandbox's boot, scrubbed of its
306
+ # project's secrets (a project's checkout booted with them, which the
307
+ # backend may not know to mask): [status, nil]. When the backend cannot
308
+ # give one (no host configured, the host unreachable, a container that
309
+ # has not written its state yet), [nil, error], where error names the
310
+ # backend and the scrubbed reason, so the page reports it instead of
311
+ # failing on a 500 (#650; Api::ProjectsController#boot_report is the
312
+ # same for a project).
313
+ def boot_report(orchestrator)
314
+ return [ nil, nil ] unless @sandbox.app_runtime? && orchestrator.supports?(:boot_status)
315
+
316
+ status = orchestrator.boot_status(@sandbox)
317
+ status = SecretScrubber.scrub(status, @sandbox.project_scrub_values) if status
318
+ [ status, nil ]
319
+ rescue StandardError => e
320
+ Rails.logger.warn("[ActionAgent] the #{orchestrator.backend_name} sandbox backend could not report the boot of " \
321
+ "sandbox #{@sandbox.session_id}: #{e.class}: #{e.message}")
322
+ [ nil, { backend: orchestrator.backend_name,
323
+ message: SecretScrubber.scrub(e.message.to_s, @sandbox.project_scrub_values).truncate(BACKEND_ERROR_BYTES) } ]
324
+ end
325
+
301
326
  def set_sandbox
302
327
  @sandbox = account_scoped(owned(SandboxSession)).find_by!(session_id: params[:id])
303
328
  end
@@ -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
 
@@ -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.
@@ -255,17 +255,19 @@ module ActionAgent
255
255
  # for one the engine judged.
256
256
  #
257
257
  # 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.
258
+ # kwargs that arrived later are passed only when Report.new takes them,
259
+ # and a result's trial only when Result carries one.
259
260
  def to_report(links: report_links, estimate: true)
260
261
  # A replay not yet scored (a fanned-out run in progress) is not a
261
262
  # result the report can grade, so it is left out rather than read as
262
263
  # a failure.
263
264
  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 ]
265
+ [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model, row.trial, row.id ]
265
266
  end
266
267
  breakdown = cost_breakdown if estimate
267
268
  selected = selected_specs
268
269
  specs = {}
270
+ trials = self.class.result_accepts?(:trial)
269
271
  results = rows.map do |row|
270
272
  spec = specs[[ row.provider.to_s, row.model ]] ||= selected[[ row.provider.to_s, row.model ]] || ModelSpec.new(
271
273
  label: [ row.provider.presence, row.model ].compact.join("/"), provider: row.provider.to_s, model: row.model
@@ -280,7 +282,8 @@ module ActionAgent
280
282
  metadata: replay_metadata_for(row, breakdown)
281
283
  ),
282
284
  scores: row.scores.to_h, score: row.score, status: row.status,
283
- diagnosis: row.evaluation_diagnosis.presence
285
+ diagnosis: row.evaluation_diagnosis.presence,
286
+ **(trials ? { trial: row.trial } : {})
284
287
  )
285
288
  end
286
289
 
@@ -317,6 +320,11 @@ module ActionAgent
317
320
  report_class.instance_method(:initialize).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
318
321
  end
319
322
 
323
+ # Whether the installed framework's Result carries +member+.
324
+ def self.result_accepts?(member)
325
+ ActiveAgent::Evals::Result.members.include?(member)
326
+ end
327
+
320
328
  # The release this run scored, as the report names it — `{ "digest",
321
329
  # "revision", "label" }` — or nil for a run pinned to a dashboard edit
322
330
  # 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