actionagent 1.7.2 → 1.8.1

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 (69) 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 +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -4,6 +4,7 @@ module ActionAgent
4
4
  module Api
5
5
  class AgentsController < BaseController
6
6
  include AgentSerialization
7
+ include RunSandbox
7
8
 
8
9
  # Ranking for the agent cards. Every dimension except "recent" reads the
9
10
  # scorecard, which is computed in Ruby over both execution sources, so
@@ -25,7 +26,7 @@ module ActionAgent
25
26
  # same reason as the rest and one more: a keyword splat wins over the
26
27
  # arguments before it, so a client sending params[params][actor] would
27
28
  # otherwise name the caller its own run is authorized as.
28
- RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user ].freeze
29
+ RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user, :runtime_sandbox ].freeze
29
30
 
30
31
  before_action :set_agent, only: [
31
32
  :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
@@ -169,13 +170,16 @@ module ActionAgent
169
170
  #
170
171
  # JSON as before, or multipart from the runner's composer: the new
171
172
  # user message, its files (attachments[]) and the conversation to
172
- # continue (params[context_id], or a top-level context_id).
173
+ # continue (params[context_id], or a top-level context_id). A
174
+ # `sandbox_id` runs it against that checkout sandbox's app runtime
175
+ # too, without editing the agent (see RunSandbox); so does /test.
173
176
  def execute
174
177
  run = @agent.execute(
175
178
  execution_prompt,
176
179
  action: params[:action_name],
177
180
  attachments: uploaded_attachments,
178
181
  actor: agent_actor,
182
+ runtime_sandbox: run_sandbox_for(@agent, params[:sandbox_id])&.runtime_server_key,
179
183
  **execution_params
180
184
  )
181
185
  record_execution_usage
@@ -190,6 +194,7 @@ module ActionAgent
190
194
  action: params[:action_name],
191
195
  attachments: uploaded_attachments,
192
196
  actor: agent_actor,
197
+ runtime_sandbox: run_sandbox_for(@agent, params[:sandbox_id])&.runtime_server_key,
193
198
  **execution_params
194
199
  )
195
200
  record_execution_usage
@@ -268,11 +273,22 @@ module ActionAgent
268
273
  # What the Tools tab edits: the MCP services this agent can be given
269
274
  # and the tools it can be offered, each with the calls, errors and
270
275
  # latency recorded for it in the window.
276
+ #
277
+ # The checkout runtimes offered are the agent owner's — the only ones
278
+ # MCPToolDispatcher reaches for this agent — and of those, only the
279
+ # ones the caller may see as well. Intersected by id rather than with
280
+ # merge: both relations constrain the same owner column, and merge
281
+ # lets the second condition replace the first instead of adding to it.
271
282
  def tool_roster
283
+ runtimes = SandboxSession.runtime_server_listings(
284
+ SandboxSession.for_owner(@agent.owner).where(id: owned(SandboxSession).select(:id))
285
+ )
286
+
272
287
  render json: AgentToolRoster.new(
273
288
  agent: @agent,
274
289
  traces: owned_traces,
275
- hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
290
+ hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i,
291
+ runtimes: runtimes
276
292
  ).as_json
277
293
  end
278
294
 
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Headless Claude Code sessions inside one of the caller's checkout
6
+ # sandboxes (Settings -> Integrations): start one with a prompt, poll its
7
+ # transcript, cancel it.
8
+ #
9
+ # A session runs on the owner's Anthropic API key (or, with
10
+ # ActionAgent.claude_code_auth = :local_login, this machine's own Claude
11
+ # Code login; see ClaudeCodeAuth) and edits the checkout, so starting one
12
+ # is execution — gated, and counted against the host app's quota, like
13
+ # running an agent.
14
+ class CodeSessionsController < BaseController
15
+ before_action :require_owner!
16
+ before_action :require_execution_enabled!, only: [ :create ]
17
+ before_action :set_sandbox
18
+ before_action :set_code_session, only: [ :show, :cancel ]
19
+
20
+ # A model name as Claude Code's --model takes it ("sonnet",
21
+ # "claude-sonnet-4-5", "claude-sonnet-4-5[1m]"). It becomes an argument
22
+ # to the CLI, so nothing that could read as another option gets there.
23
+ MODEL_NAME = /\A[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,99}\z/
24
+
25
+ # GET /api/sandboxes/:sandbox_id/code_sessions
26
+ def index
27
+ render json: { code_sessions: @sandbox.code_sessions.recent.limit(20).map(&:summary) }
28
+ end
29
+
30
+ # POST /api/sandboxes/:sandbox_id/code_sessions
31
+ def create
32
+ runner = params[:runner].nil? ? "claude_code" : params[:runner]
33
+ unless CodeSession::RUNNERS.include?(runner)
34
+ return render json: { error: "runner must be claude_code or codex" }, status: :unprocessable_entity
35
+ end
36
+ if (refusal = refusal_for(@sandbox, runner: runner))
37
+ return render json: { error: refusal }, status: :unprocessable_entity
38
+ end
39
+
40
+ if (current = busy_session)
41
+ return render json: busy_body(current), status: :conflict
42
+ end
43
+
44
+ code_session = CodeSession.new(
45
+ sandbox_session: @sandbox,
46
+ prompt: string_param(:prompt),
47
+ model: string_param(:model).presence,
48
+ runner: runner,
49
+ # Owned like the sandbox it runs in.
50
+ user_id: @sandbox.try(:user_id),
51
+ account_id: @sandbox.try(:account_id)
52
+ )
53
+ if (error = invalid_request(code_session))
54
+ return render json: { error: error }, status: :unprocessable_entity
55
+ end
56
+
57
+ enforce_execution_quota!
58
+ return if performed?
59
+
60
+ # Checked again under the sandbox's row lock, so two requests racing
61
+ # past the check above cannot both start a session in one checkout.
62
+ current = nil
63
+ @sandbox.with_lock do
64
+ current = busy_session
65
+ code_session.save! unless current
66
+ end
67
+ return render json: busy_body(current), status: :conflict if current
68
+
69
+ record_execution_usage
70
+ CodeSessionJob.perform_later(code_session.id)
71
+
72
+ render json: { code_session: code_session.summary }, status: :created
73
+ end
74
+
75
+ # GET /api/sandboxes/:sandbox_id/code_sessions/:id?after=N
76
+ # The transcript from event N onward, for incremental polling.
77
+ def show
78
+ render json: { code_session: @code_session.details(after: integer_param(:after, default: 0)) }
79
+ end
80
+
81
+ # POST /api/sandboxes/:sandbox_id/code_sessions/:id/cancel
82
+ def cancel
83
+ was_running = false
84
+ @code_session.with_lock do
85
+ next unless @code_session.queued? || @code_session.running?
86
+
87
+ was_running = @code_session.running?
88
+ # A queued session is settled at once: nothing will ever run it. A
89
+ # running one is settled by CodeSessionJob once its Claude Code has
90
+ # stopped and its diff was taken (see CodeSession#diff_pending?).
91
+ @code_session.update!(status: :cancelled, finished_at: was_running ? nil : Time.current)
92
+ end
93
+
94
+ # A queued session never started: CodeSessionJob skips it. A running
95
+ # one is stopped by its backend; CodeSessionJob then finds it
96
+ # cancelled and leaves it so.
97
+ stop(@code_session) if was_running
98
+
99
+ render json: { code_session: @code_session.summary }
100
+ end
101
+
102
+ private
103
+
104
+ # A checkout runs on its account's GitHub token and Claude Code
105
+ # credential, so in a multi-tenant install it must belong to the
106
+ # caller's current account too — not only to the caller, who may have
107
+ # left that account or switched to another.
108
+ def set_sandbox
109
+ scope = owned(SandboxSession)
110
+ scope = scope.where(account_id: current_account.id) if current_account
111
+ @sandbox = scope.find_by!(session_id: params[:sandbox_id])
112
+ end
113
+
114
+ def set_code_session
115
+ @code_session = @sandbox.code_sessions.find(params[:id])
116
+ end
117
+
118
+ # Why +sandbox+ cannot take a Claude Code session now, or nil.
119
+ def refusal_for(sandbox, runner: "claude_code")
120
+ label = runner == "codex" ? "Codex" : "Claude Code"
121
+ return "#{label} sessions run only in a checkout (app_runtime) sandbox" unless sandbox.app_runtime?
122
+ return "The sandbox has expired; start a new one" if sandbox.ready? && !sandbox.active?
123
+ return "The sandbox is #{sandbox.status}; wait until it is ready" unless sandbox.ready?
124
+
125
+ orchestrator = SandboxOrchestrator.new
126
+ unless orchestrator.supports_code_runner?(runner)
127
+ return "The #{orchestrator.backend_name} sandbox backend cannot run #{label} sessions"
128
+ end
129
+
130
+ if runner == "codex"
131
+ return "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first" if sandbox.runtime_environment(runner: runner).blank?
132
+ return
133
+ end
134
+ ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox)
135
+ end
136
+
137
+ # The session of this sandbox that is queued or running, if any: one
138
+ # checkout, one Claude Code at a time.
139
+ def busy_session
140
+ @sandbox.code_sessions.where(status: [ :queued, :running ]).first
141
+ end
142
+
143
+ def busy_body(current)
144
+ label = current.runner == "codex" ? "Codex" : "Claude Code"
145
+ { error: "A #{label} session is already #{current.status} in this sandbox", code_session: current.summary }
146
+ end
147
+
148
+ def invalid_request(code_session)
149
+ label = code_session.runner == "codex" ? "Codex" : "Claude Code"
150
+ return "model is not a #{label} model name" if code_session.model && !code_session.model.match?(MODEL_NAME)
151
+
152
+ code_session.errors.full_messages.to_sentence unless code_session.valid?
153
+ end
154
+
155
+ # A prompt or model sent as an array or object is not one.
156
+ def string_param(name)
157
+ value = params[name]
158
+ value.is_a?(String) ? value : nil
159
+ end
160
+
161
+ def stop(code_session)
162
+ SandboxOrchestrator.new.cancel_code_session(@sandbox, code_session)
163
+ rescue StandardError => e
164
+ Rails.logger.warn("Failed to stop Claude Code session #{code_session.id}: #{e.message}")
165
+ end
166
+ end
167
+ end
168
+ end
@@ -10,6 +10,8 @@ module ActionAgent
10
10
  # than sampling recorded generations, and can be narrowed to a group, to
11
11
  # specific scenarios, or to specific models.
12
12
  class EvaluationsController < BaseController
13
+ include EvaluationRunStarting
14
+
13
15
  rescue_from ActiveAgent::Evals::ScenarioParser::ParseError do |error|
14
16
  render json: { errors: [ error.message ] }, status: :unprocessable_entity
15
17
  end
@@ -39,6 +41,11 @@ module ActionAgent
39
41
  # 50 most recent. The scope is already restricted to the current user's
40
42
  # agents, so an id outside it simply returns nothing.
41
43
  #
44
+ # Archived evaluations are left out the same way, before the limit,
45
+ # unless `archived=1` asks for them; `archived_count` says how many
46
+ # the page left out. Each evaluation carries its standing against the
47
+ # agent's current version and its headline run (EvaluationSerializer).
48
+ #
42
49
  # Three fields feed the dashboard's model pickers. They describe the
43
50
  # credentials of #picker_credentials_owner:
44
51
  # - judge_provider: the provider a judge model runs on, null
@@ -52,16 +59,36 @@ module ActionAgent
52
59
  def index
53
60
  scope = evaluations_scope
54
61
  scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
55
- evaluations = scope.includes(:agent, :evaluation_runs, :scenarios).recent.limit(50)
62
+ archived_count = scope.archived.count
63
+ scope = scope.unarchived unless include_archived?
64
+ evaluations = scope.includes(:agent, :scenarios, evaluation_runs: :agent_version).recent.limit(50).to_a
65
+ preload_standing_and_costs(evaluations)
56
66
  owner = picker_credentials_owner
57
67
 
58
68
  render json: {
59
69
  evaluations: evaluations.map { |evaluation| serialize(evaluation) },
70
+ archived_count: archived_count,
60
71
  **judge_provider_fields(owner),
61
72
  model_providers: AgentExecutionService.available_providers(owner)
62
73
  }
63
74
  end
64
75
 
76
+ # PATCH /api/evaluations/:id
77
+ # `evaluation: { archived: true | false }` archives the evaluation —
78
+ # it keeps its runs but leaves the index, the pooled pass rate and the
79
+ # agent's scorecard — or brings it back. A new run brings it back too.
80
+ def update
81
+ evaluation = evaluations_scope.find(params[:id])
82
+ archived = params.require(:evaluation)[:archived]
83
+ if archived.nil?
84
+ return render json: { errors: [ "evaluation.archived must be true or false" ] }, status: :unprocessable_entity
85
+ end
86
+
87
+ ActiveModel::Type::Boolean.new.cast(archived) ? evaluation.archive! : evaluation.unarchive!
88
+
89
+ render json: { evaluation: serialize(evaluation.reload) }
90
+ end
91
+
65
92
  # Runs listed per evaluation on GET /api/evaluations/:id. The rest of
66
93
  # the history stays reachable by run id; `run_count` says how long it is.
67
94
  RUN_HISTORY_LIMIT = 20
@@ -70,7 +97,8 @@ module ActionAgent
70
97
  def show
71
98
  evaluation = evaluations_scope.find(params[:id])
72
99
  run_count = evaluation.evaluation_runs.count
73
- runs = evaluation.evaluation_runs.recent.limit(RUN_HISTORY_LIMIT).to_a
100
+ runs = evaluation.evaluation_runs.includes(:agent_version).recent.limit(RUN_HISTORY_LIMIT).to_a
101
+ EvaluationRunCost.preload(runs).each { |id, breakdown| runs.find { |run| run.id == id }&.cost_breakdown = breakdown }
74
102
 
75
103
  render json: {
76
104
  evaluation: serialize(evaluation).merge(
@@ -107,7 +135,7 @@ module ActionAgent
107
135
  end
108
136
 
109
137
  if evaluation.save
110
- start_run(evaluation, selection_params) if run_requested?
138
+ start_evaluation_run(evaluation, selection_params) if run_requested?
111
139
  render json: { evaluation: serialize(evaluation.reload) }, status: :created
112
140
  else
113
141
  render json: { errors: evaluation.errors.full_messages }, status: :unprocessable_entity
@@ -116,10 +144,17 @@ module ActionAgent
116
144
 
117
145
  # POST /api/evaluations/:id/run
118
146
  # A scenario suite accepts a selection: scenario_ids[], keys[], group,
119
- # models[] (or a comma-separated `models` string).
147
+ # models[] (or a comma-separated `models` string), and `sandbox_id`: a
148
+ # checkout sandbox of the caller's whose app runtime every replay of
149
+ # this run reaches as well, without the agent being edited (see
150
+ # RunSandbox). The run records which one it used.
120
151
  def run
121
152
  evaluation = current_evaluation
122
- run = start_run(evaluation, selection_params)
153
+ selection = selection_params
154
+ if (sandbox = evaluation_run_sandbox(evaluation, requested_sandbox_id))
155
+ selection[:sandbox_id] = sandbox.session_id
156
+ end
157
+ run = start_evaluation_run(evaluation, selection)
123
158
  evaluation.reload
124
159
 
125
160
  render json: {
@@ -134,16 +169,21 @@ module ActionAgent
134
169
  # fix items — the faults grouped with the tools, MCP server and
135
170
  # dashboard action that address each. Fix item paths are relative to
136
171
  # the mount: the React app resolves them itself (dashboardPath).
172
+ # `costs` is what each scenario cost across the models, judge apart,
173
+ # and the run's total (EvaluationRunCost#costs); every result carries
174
+ # its effective cost and how it was priced.
137
175
  def show_run
138
176
  evaluation = evaluations_scope.find(params[:id])
139
177
  run = evaluation.evaluation_runs.find(params[:run_id])
140
178
  results = run.scenario_results.includes(:scenario).joins(:scenario)
141
179
  .order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
180
+ breakdown = run.cost_breakdown
142
181
 
143
182
  render json: {
144
183
  evaluation: serialize(evaluation),
145
184
  run: serialize_run(run, number: run_number(evaluation, run))
146
- .merge(results: results.map(&:as_json_summary), fix_items: safe_fix_items(run))
185
+ .merge(results: results.map { |result| result.as_json_summary(costs: breakdown.result(result)) },
186
+ fix_items: safe_fix_items(run), costs: breakdown.costs)
147
187
  }
148
188
  end
149
189
 
@@ -216,28 +256,19 @@ module ActionAgent
216
256
 
217
257
  private
218
258
 
219
- # A scenario suite replays through the provider once per scenario and
220
- # model, so it runs in the background; a generation-sampling evaluation
221
- # scores recorded data and finishes inline.
222
- def start_run(evaluation, selection)
223
- return evaluation.run_later!(**selection) if evaluation.scenario_suite?
224
-
225
- # EvaluationRunnerService marks the run failed with the error message
226
- # and then re-raises. Letting that escape returned an HTML 500 for a
227
- # request that had already persisted the evaluation and its failed
228
- # run: the client saw a JSON parse error, the form stayed open, and a
229
- # resubmit failed on the now-taken name. The failure is on the run
230
- # record, which is what the response carries.
231
- evaluation.run!
232
- rescue StandardError => e
233
- Rails.logger.warn(
234
- "[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
235
- )
236
- # The service records the failure before re-raising; a failure that
237
- # predates the run record (creating it, say) is recorded here so the
238
- # response always carries one.
239
- evaluation.evaluation_runs.recent.first ||
240
- evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
259
+ def include_archived?
260
+ ActiveModel::Type::Boolean.new.cast(params[:archived]) == true
261
+ end
262
+
263
+ # One query per table for the page's standings and for the costs of
264
+ # the runs it serializes in full (each evaluation's latest and headline
265
+ # run), rather than one per evaluation.
266
+ def preload_standing_and_costs(evaluations)
267
+ EvaluationStanding.preload(evaluations)
268
+ runs = evaluations.flat_map do |evaluation|
269
+ [ EvaluationSerializer.recent_runs(evaluation, 1).first, evaluation.standing_info.headline_run ]
270
+ end.compact.uniq(&:id)
271
+ EvaluationRunCost.preload(runs).each { |id, breakdown| runs.find { |run| run.id == id }&.cost_breakdown = breakdown }
241
272
  end
242
273
 
243
274
  # Returns whose credentials the index's model picker fields describe.
@@ -292,10 +323,7 @@ module ActionAgent
292
323
  def require_executable_scenario_agent!
293
324
  agent = action_name == "create" ? requested_agent : current_evaluation.agent
294
325
  return unless agent.observed?
295
- if action_name == "run"
296
- adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(current_evaluation)
297
- return if adapter.respond_to?(:call)
298
- end
326
+ return if action_name == "run" && !unexecutable_scenario_run?(current_evaluation)
299
327
 
300
328
  render json: {
301
329
  error: "Observed agents are read-only — duplicate this agent to create an executable copy"
@@ -310,19 +338,18 @@ module ActionAgent
310
338
  params.require(:scenario).permit(:prompt, :group, :notes, :enabled, :key, expectations: {})
311
339
  end
312
340
 
313
- # scenario_ids, keys, group and models narrow a scenario run. `models`
314
- # may arrive as an array or as the comma-separated field the form posts.
341
+ # The sandbox id a run was asked to use, from the selection or the top
342
+ # level of the request.
343
+ def requested_sandbox_id
344
+ selection_source[:sandbox_id].presence || params[:sandbox_id].presence
345
+ end
346
+
315
347
  def selection_params
316
- source = params[:evaluation].is_a?(ActionController::Parameters) && params[:evaluation].key?(:selection) ? params[:evaluation][:selection] : params
317
- models = source[:models]
318
- models = models.to_s.split(",") unless models.is_a?(Array)
348
+ evaluation_run_selection(selection_source)
349
+ end
319
350
 
320
- {
321
- scenario_ids: Array(source[:scenario_ids]).map(&:to_s).reject(&:blank?),
322
- keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
323
- group: source[:group].to_s.presence,
324
- models: models.map(&:to_s).map(&:strip).reject(&:blank?)
325
- }.compact_blank
351
+ def selection_source
352
+ params[:evaluation].is_a?(ActionController::Parameters) && params[:evaluation].key?(:selection) ? params[:evaluation][:selection] : params
326
353
  end
327
354
 
328
355
  # Scenarios from text, a YAML/JSON suite, or a list of objects. Production
@@ -373,97 +400,13 @@ module ActionAgent
373
400
  models.map(&:to_s).map(&:strip).reject(&:blank?)
374
401
  end
375
402
 
376
- def serialize(evaluation)
377
- # size reads the preloaded association on index and COUNTs elsewhere.
378
- run_count = evaluation.evaluation_runs.size
379
- latest, previous = recent_runs(evaluation, 2)
380
-
381
- {
382
- id: evaluation.id,
383
- name: evaluation.name,
384
- agent: { id: evaluation.agent.id, name: evaluation.agent.name, slug: evaluation.agent.slug },
385
- judge_kind: evaluation.judge_kind,
386
- judge_model: evaluation.judge_model,
387
- criteria: evaluation.criteria,
388
- compare_models: evaluation.compare_models,
389
- config: evaluation.config,
390
- sample_size: evaluation.sample_size,
391
- scenario_suite: evaluation.scenario_suite?,
392
- scenario_count: evaluation.scenarios.size,
393
- scenario_groups: evaluation.scenario_suite? ? evaluation.scenario_groups : [],
394
- created_at: evaluation.created_at.iso8601,
395
- run_count: run_count,
396
- latest_run: latest ? serialize_run(latest, number: run_count) : nil,
397
- # Just enough of the run before it for the list to show movement
398
- # ("+3 passed vs #2") without a request per evaluation.
399
- previous_run: previous ? serialize_run_summary(previous, number: run_count - 1) : nil
400
- }
401
- end
402
-
403
- # Newest first. Sorts the preloaded association when index loaded it
404
- # rather than issuing one ORDER BY query per evaluation.
405
- def recent_runs(evaluation, limit)
406
- runs = evaluation.evaluation_runs
407
- if runs.loaded?
408
- runs.sort_by { |run| [ run.created_at, run.id ] }.reverse.first(limit)
409
- else
410
- runs.recent.limit(limit).to_a
411
- end
412
- end
413
-
414
- # A run's position in its evaluation's history, oldest = 1.
415
- def run_number(evaluation, run)
416
- evaluation.evaluation_runs.where("created_at < ? OR (created_at = ? AND id <= ?)", run.created_at, run.created_at, run.id).count
417
- end
418
-
419
- # `number` is the run's position in its evaluation's history, oldest =
420
- # 1, so the dashboard can say "Run #3" and "vs #2".
421
- def serialize_run(run, number: nil)
422
- serialize_run_summary(run, number: number).merge(
423
- scores: run.scores,
424
- selection: run.selection,
425
- models: run.models,
426
- usage: run.usage,
427
- error_message: run.error_message
428
- )
429
- end
403
+ def serialize(evaluation) = EvaluationSerializer.evaluation(evaluation)
430
404
 
431
- def serialize_run_summary(run, number: nil)
432
- {
433
- id: run.id,
434
- number: number,
435
- status: run.status,
436
- average_score: safe_average_score(run),
437
- samples_evaluated: run.samples_evaluated,
438
- samples_passed: run.samples_passed,
439
- completed_at: run.completed_at&.iso8601,
440
- created_at: run.created_at.iso8601
441
- }
442
- end
405
+ def serialize_run(run, number: nil) = EvaluationSerializer.run(run, number: number)
443
406
 
444
- # The fix items are derived from every persisted result's diagnosis,
445
- # which older runs recorded in earlier shapes; a run they cannot be
446
- # built for still serves its results rather than 500-ing the panel.
447
- def safe_fix_items(run)
448
- run.fix_items
449
- rescue StandardError => e
450
- Rails.logger.warn(
451
- "[ActionAgent] evaluation run #{run.id} fix_items failed: #{e.class}: #{e.message}"
452
- )
453
- []
454
- end
407
+ def run_number(evaluation, run) = EvaluationSerializer.run_number(evaluation, run)
455
408
 
456
- # index serializes the latest run of every listed evaluation, so an
457
- # unaverageable scores payload used to 500 the entire Evaluations page
458
- # instead of degrading that one run's headline number.
459
- def safe_average_score(run)
460
- run.average_score
461
- rescue StandardError => e
462
- Rails.logger.warn(
463
- "[ActionAgent] evaluation run #{run.id} average_score failed: #{e.class}: #{e.message}"
464
- )
465
- nil
466
- end
409
+ def safe_fix_items(run) = EvaluationSerializer.fix_items(run)
467
410
  end
468
411
  end
469
412
  end