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
@@ -0,0 +1,509 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The dashboard's own evaluation and telemetry tools, served by the MCP
6
+ # facade (Api::MCPController) so a client's coding harness can edit an
7
+ # agent in its own checkout, run the agent's evaluations, read the runs'
8
+ # fix items and failing traces, and iterate. The harness brings its own
9
+ # model; the dashboard only answers these calls.
10
+ #
11
+ # Each tool reads under the key's owner the way the dashboard's JSON API
12
+ # reads under the signed-in owner: evaluations of the agents the owner
13
+ # can reach (Api::EvaluationsController), traces of the owner's tenant
14
+ # (Api::TraceReportsController). A record outside that scope reads as
15
+ # nonexistent.
16
+ #
17
+ # Names are a noun family followed by a verb (`evaluations_list`,
18
+ # `traces_get`). Host schema tools are always `find_`, `count_` or `get_`
19
+ # plus a model name, and agent tools are `run_<slug>`, so no host model or
20
+ # agent slug can produce one of these names.
21
+ #
22
+ # A call the client can correct (an unknown id, a sandbox it cannot use)
23
+ # comes back as a tool result with isError. The execution switch and the
24
+ # execution quota answer as JSON-RPC errors, as they do for `run_<slug>`.
25
+ module MCPDashboardTools
26
+ extend ActiveSupport::Concern
27
+
28
+ include EvaluationRunStarting
29
+
30
+ NAMES = %w[
31
+ evaluations_list evaluations_get evaluations_run evaluation_runs_get evaluation_runs_compare
32
+ traces_search traces_get
33
+ ].freeze
34
+
35
+ LIST_LIMIT = 20
36
+ MAX_LIST_LIMIT = 50
37
+ SCENARIO_LIMIT = 200
38
+ RUN_HISTORY_LIMIT = 10
39
+ RESULT_LIMIT = 50
40
+ MAX_RESULT_LIMIT = 200
41
+ TRACE_WINDOW_MINUTES = 60 * 24
42
+ MAX_TRACE_WINDOW_MINUTES = TraceReportsController::MAX_WINDOW_MINUTES
43
+ TRACE_LIMIT = 20
44
+ MAX_TRACE_LIMIT = 100
45
+ MAX_TRACE_SPANS = 100
46
+ # Strings in a result row or a span, cut past this many characters.
47
+ MAX_STRING = 1_000
48
+ # Owned credentials read for scrubbing, per kind.
49
+ SECRET_LOOKUP_LIMIT = 100
50
+
51
+ SELECTION_PROPERTIES = {
52
+ scenario_ids: { type: "array", items: { type: "integer" }, description: "Replay only these scenarios, by id" },
53
+ keys: { type: "array", items: { type: "string" }, description: "Replay only these scenarios, by key" },
54
+ group: { type: "string", description: "Replay only this scenario group" },
55
+ models: { type: "array", items: { type: "string" }, description: "Candidate models to compare; the agent's own model when omitted" },
56
+ sandbox_id: {
57
+ type: "string",
58
+ description: "A ready checkout sandbox's session id: every replay reaches that checkout's app tools, " \
59
+ "without the agent being edited"
60
+ }
61
+ }.freeze
62
+
63
+ DEFINITIONS = [
64
+ {
65
+ name: "evaluations_list",
66
+ description: "List the evaluations of this key's agents, newest first, each with its latest run's status and " \
67
+ "score. Filter to one agent by slug or id.",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ agent: { type: "string", description: "An agent's slug or id" },
72
+ limit: { type: "integer", description: "At most this many (default #{LIST_LIMIT}, max #{MAX_LIST_LIMIT})" }
73
+ }
74
+ }
75
+ },
76
+ {
77
+ name: "evaluations_get",
78
+ description: "One evaluation: its criteria, its scenarios (for a scenario suite) and its #{RUN_HISTORY_LIMIT} " \
79
+ "most recent runs.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: { evaluation_id: { type: "integer", description: "The evaluation's id" } },
83
+ required: [ "evaluation_id" ]
84
+ }
85
+ },
86
+ {
87
+ name: "evaluations_run",
88
+ description: "Start a run of an evaluation. A scenario suite replays each scenario through the agent in the " \
89
+ "background: the run comes back pending, so poll evaluation_runs_get with its id. A sampling " \
90
+ "evaluation scores recorded generations and finishes before this returns.",
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: { evaluation_id: { type: "integer", description: "The evaluation's id" } }.merge(SELECTION_PROPERTIES),
94
+ required: [ "evaluation_id" ]
95
+ }
96
+ },
97
+ {
98
+ name: "evaluation_runs_get",
99
+ description: "One evaluation run: status, scores, usage, its fix items (what to change, and where) and its " \
100
+ "per-scenario, per-model results, each naming its telemetry trace (for traces_get) when one " \
101
+ "was recorded. Defaults to the evaluation's latest run.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ evaluation_id: { type: "integer", description: "The evaluation's id" },
106
+ run_id: { type: "integer", description: "The run's id; the latest run when omitted" },
107
+ failed_only: { type: "boolean", description: "Only results that did not pass" },
108
+ limit: { type: "integer", description: "At most this many results (default #{RESULT_LIMIT}, max #{MAX_RESULT_LIMIT})" }
109
+ },
110
+ required: [ "evaluation_id" ]
111
+ }
112
+ },
113
+ {
114
+ name: "evaluation_runs_compare",
115
+ description: "Compare two runs of one evaluation, scenario by scenario and model by model: which results " \
116
+ "were fixed, which regressed, which still fail. Defaults to the latest run against the one before it.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ evaluation_id: { type: "integer", description: "The evaluation's id" },
121
+ base_run_id: { type: "integer", description: "The earlier run's id" },
122
+ head_run_id: { type: "integer", description: "The later run's id" }
123
+ },
124
+ required: [ "evaluation_id" ]
125
+ }
126
+ },
127
+ {
128
+ name: "traces_search",
129
+ description: "Search telemetry traces, newest first. Returns summary rows (no spans); read one in full with " \
130
+ "traces_get.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ agent: { type: "string", description: "An agent class name (as traces report it) or a dashboard agent's slug" },
135
+ status: { type: "string", enum: %w[error ok], description: "Only failed traces, or only successful ones" },
136
+ service: { type: "string", description: "The reporting service's name" },
137
+ since_minutes: {
138
+ type: "integer",
139
+ description: "How far back to look (default #{TRACE_WINDOW_MINUTES}, max #{MAX_TRACE_WINDOW_MINUTES})"
140
+ },
141
+ min_tokens: { type: "integer", description: "Only traces that used at least this many input + output tokens" },
142
+ min_duration_ms: { type: "integer", description: "Only traces that took at least this long" },
143
+ limit: { type: "integer", description: "At most this many (default #{TRACE_LIMIT}, max #{MAX_TRACE_LIMIT})" }
144
+ }
145
+ }
146
+ },
147
+ {
148
+ name: "traces_get",
149
+ description: "One trace in full: its spans, tool calls with their arguments and results, tokens, estimated " \
150
+ "cost and errors. Values longer than #{MAX_STRING} characters are cut and marked.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: { trace_id: { type: "string", description: "The trace's id, its OpenTelemetry trace id, or that id's first 8 characters" } },
154
+ required: [ "trace_id" ]
155
+ }
156
+ }
157
+ ].freeze
158
+
159
+ # Raised by a tool for a call the client can correct. Answered as a tool
160
+ # result with isError.
161
+ class ToolError < StandardError; end
162
+
163
+ private
164
+
165
+ def dashboard_tools_list
166
+ ActionAgent.mcp_dashboard_tools? ? DEFINITIONS.map(&:dup) : []
167
+ end
168
+
169
+ def dashboard_tool?(name)
170
+ ActionAgent.mcp_dashboard_tools? && NAMES.include?(name)
171
+ end
172
+
173
+ def dashboard_tool_call(name)
174
+ dashboard_tool_result(dispatch_dashboard_tool(name))
175
+ rescue ToolError, RunSandbox::Refused => e
176
+ message = SecretScrubber.scrub(e.message, dashboard_tool_secrets)
177
+ { content: [ { type: "text", text: message } ], structuredContent: { error: message }, isError: true }
178
+ end
179
+
180
+ def dispatch_dashboard_tool(name)
181
+ case name
182
+ when "evaluations_list" then evaluations_list_tool
183
+ when "evaluations_get" then evaluations_get_tool
184
+ when "evaluations_run" then evaluations_run_tool
185
+ when "evaluation_runs_get" then evaluation_runs_get_tool
186
+ when "evaluation_runs_compare" then evaluation_runs_compare_tool
187
+ when "traces_search" then traces_search_tool
188
+ when "traces_get" then traces_get_tool
189
+ end
190
+ end
191
+
192
+ # The payload as JSON-compatible data with every credential the owner
193
+ # holds masked.
194
+ def dashboard_tool_result(payload)
195
+ payload = SecretScrubber.scrub(payload.as_json, dashboard_tool_secrets)
196
+ { content: [ { type: "text", text: payload.to_json } ], structuredContent: payload }
197
+ end
198
+
199
+ def evaluations_list_tool
200
+ scope = dashboard_evaluations
201
+ if (agent = tool_argument(:agent).presence)
202
+ scope = scope.where(agent: resolve_tool_agent!(agent))
203
+ end
204
+ limit = integer_argument(:limit, default: LIST_LIMIT, min: 1, max: MAX_LIST_LIMIT)
205
+ evaluations = scope.includes(:agent, :scenarios, evaluation_runs: :agent_version).recent.limit(limit).to_a
206
+ EvaluationStanding.preload(evaluations)
207
+
208
+ {
209
+ evaluations: evaluations.map do |evaluation|
210
+ summary = EvaluationSerializer.summary(evaluation)
211
+ latest = EvaluationSerializer.recent_runs(evaluation, 1).first
212
+ summary.slice(:id, :name, :agent, :judge_kind, :scenario_suite, :scenario_count, :scenario_groups, :created_at, :run_count,
213
+ :headline_run_id, :standing, :archived_at)
214
+ .merge(latest_run: latest && EvaluationSerializer.run_summary(latest, number: summary[:run_count]))
215
+ end
216
+ }
217
+ end
218
+
219
+ def evaluations_get_tool
220
+ evaluation = find_tool_evaluation!
221
+ run_count = evaluation.evaluation_runs.count
222
+ runs = evaluation.evaluation_runs.recent.limit(RUN_HISTORY_LIMIT).to_a
223
+ scenarios = evaluation.scenarios.ordered.limit(SCENARIO_LIMIT + 1).map(&:as_json_summary)
224
+
225
+ {
226
+ evaluation: EvaluationSerializer.summary(evaluation),
227
+ scenarios: PayloadBounds.bound(scenarios.first(SCENARIO_LIMIT), max_string: MAX_STRING, max_items: SCENARIO_LIMIT),
228
+ scenarios_truncated: scenarios.size > SCENARIO_LIMIT,
229
+ runs: runs.each_with_index.map { |run, index| EvaluationSerializer.run_summary(run, number: run_count - index) }
230
+ }
231
+ end
232
+
233
+ # Checked in the order Api::EvaluationsController#run checks a request:
234
+ # the execution switch, an executable agent, the execution quota, then
235
+ # the sandbox. Only a scenario suite executes the agent, so a sampling
236
+ # run passes the first three. Each replay counts one execution as it
237
+ # runs (ScenarioEvaluationRunner), so starting the run records none.
238
+ def evaluations_run_tool
239
+ evaluation = find_tool_evaluation!
240
+ if evaluation.scenario_suite?
241
+ raise MCPController::McpError.new("Agent execution is disabled on this dashboard") unless ActionAgent.execution_enabled?
242
+ if unexecutable_scenario_run?(evaluation)
243
+ raise ToolError, "Observed agents are read-only — duplicate this agent to create an executable copy"
244
+ end
245
+ if (denial = ActionAgent.quota_denial(current_owner, :execution)).present?
246
+ raise MCPController::McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
247
+ end
248
+ end
249
+
250
+ selection = evaluation_run_selection(tool_arguments)
251
+ if (sandbox = evaluation_run_sandbox(evaluation, tool_argument(:sandbox_id).presence))
252
+ selection[:sandbox_id] = sandbox.session_id
253
+ end
254
+ run = start_evaluation_run(evaluation, selection)
255
+
256
+ {
257
+ evaluation: { id: evaluation.id, name: evaluation.name },
258
+ run: EvaluationSerializer.run_summary(run, number: EvaluationSerializer.run_number(evaluation, run))
259
+ .merge(selection: run.selection, error_message: run.error_message),
260
+ background: evaluation.scenario_suite?
261
+ }
262
+ end
263
+
264
+ def evaluation_runs_get_tool
265
+ evaluation = find_tool_evaluation!
266
+ run = find_tool_run!(evaluation, tool_argument(:run_id), label: "run_id")
267
+ limit = integer_argument(:limit, default: RESULT_LIMIT, min: 1, max: MAX_RESULT_LIMIT)
268
+
269
+ results = run.scenario_results.includes(:scenario, :agent_run).joins(:scenario)
270
+ .order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
271
+ results = results.where.not(status: :passed) if boolean_argument(:failed_only)
272
+ total = results.count
273
+ page = results.limit(limit).to_a
274
+ recorded = recorded_trace_ids(page.filter_map { |result| result.agent_run&.trace_id })
275
+ rows = page.map { |result| result_row(result, recorded) }
276
+
277
+ {
278
+ evaluation: { id: evaluation.id, name: evaluation.name, agent: evaluation.agent.slug },
279
+ run: EvaluationSerializer.run(run, number: EvaluationSerializer.run_number(evaluation, run)),
280
+ fix_items: PayloadBounds.bound(EvaluationSerializer.fix_items(run, links: dashboard_links(run)), max_string: MAX_STRING),
281
+ results: rows.map { |row| PayloadBounds.bound(row, max_string: MAX_STRING, max_items: 20) },
282
+ results_total: total,
283
+ results_omitted: [ total - rows.size, 0 ].max
284
+ }
285
+ end
286
+
287
+ def evaluation_runs_compare_tool
288
+ evaluation = find_tool_evaluation!
289
+ base_id = tool_argument(:base_run_id)
290
+ head_id = tool_argument(:head_run_id)
291
+ head = find_tool_run!(evaluation, head_id, label: "head_run_id")
292
+ base =
293
+ if base_id.present?
294
+ find_tool_run!(evaluation, base_id, label: "base_run_id")
295
+ else
296
+ evaluation.evaluation_runs.where("created_at < ? OR (created_at = ? AND id < ?)", head.created_at, head.created_at, head.id)
297
+ .recent.first or raise ToolError, "Run #{head.id} is the evaluation's first run; there is nothing to compare it with"
298
+ end
299
+
300
+ base_rows = comparison_rows(base)
301
+ head_rows = comparison_rows(head)
302
+ changes = (base_rows.keys | head_rows.keys).filter_map do |key|
303
+ before = base_rows[key]
304
+ after = head_rows[key]
305
+ change = comparison_change(before, after)
306
+ next if change == "unchanged_pass"
307
+
308
+ { scenario_key: key[0], model: key[1], change: change, before: before&.status, after: after&.status,
309
+ fault: after&.fault, result_id: after&.id }
310
+ end
311
+
312
+ {
313
+ evaluation: { id: evaluation.id, name: evaluation.name },
314
+ base_run: EvaluationSerializer.run_summary(base, number: EvaluationSerializer.run_number(evaluation, base)),
315
+ head_run: EvaluationSerializer.run_summary(head, number: EvaluationSerializer.run_number(evaluation, head)),
316
+ counts: changes.group_by { |change| change[:change] }.transform_values(&:size),
317
+ changes: PayloadBounds.bound(changes, max_items: MAX_RESULT_LIMIT)
318
+ }
319
+ end
320
+
321
+ def traces_search_tool
322
+ window = integer_argument(:since_minutes, default: TRACE_WINDOW_MINUTES, min: 1, max: MAX_TRACE_WINDOW_MINUTES)
323
+ limit = integer_argument(:limit, default: TRACE_LIMIT, min: 1, max: MAX_TRACE_LIMIT)
324
+ model = ActionAgent.trace_model
325
+
326
+ scope = owned_traces.for_date_range(window.minutes.ago, Time.current)
327
+ if (agent = tool_argument(:agent).presence)
328
+ by_slug = owner_agents.find_by(slug: agent.to_s)
329
+ scope = by_slug ? scope.where(agent_id: by_slug.id).or(scope.for_agent(agent.to_s)) : scope.for_agent(agent.to_s)
330
+ end
331
+ scope = scope.for_service(tool_argument(:service).to_s) if tool_argument(:service).present?
332
+ case tool_argument(:status).to_s.downcase
333
+ when "error" then scope = scope.with_errors
334
+ when "ok" then scope = scope.where.not(status: model::STATUS_ERROR)
335
+ end
336
+ if (min_tokens = integer_argument(:min_tokens, default: nil))
337
+ table = model.quoted_table_name
338
+ scope = scope.where(
339
+ "COALESCE(#{table}.total_input_tokens, 0) + COALESCE(#{table}.total_output_tokens, 0) >= ?", min_tokens
340
+ )
341
+ end
342
+ if (min_duration = integer_argument(:min_duration_ms, default: nil))
343
+ scope = scope.where(model.arel_table[:total_duration_ms].gteq(min_duration))
344
+ end
345
+
346
+ traces = scope.recent.limit(limit + 1).to_a
347
+
348
+ {
349
+ traces: traces.first(limit).map { |trace| PayloadBounds.bound(TelemetryTraceSerializer.row(trace), max_string: MAX_STRING) },
350
+ truncated: traces.size > limit,
351
+ window_minutes: window
352
+ }
353
+ end
354
+
355
+ # Looked up as Api::TraceReportsController#show looks a trace up: the
356
+ # OpenTelemetry trace id, then a prefix of it, then the record id.
357
+ def traces_get_tool
358
+ id = tool_argument(:trace_id).to_s
359
+ raise MCPController::McpError.new("Missing required argument: trace_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
360
+
361
+ scope = owned_traces
362
+ trace = scope.find_by(trace_id: id) ||
363
+ scope.where("trace_id LIKE ?", "#{ActionAgent.trace_model.sanitize_sql_like(id)}%").first ||
364
+ (id.match?(/\A\d+\z/) && scope.find_by(id: id)) or
365
+ raise ToolError, "No trace #{id.truncate(64)} was found"
366
+
367
+ detail = TelemetryTraceSerializer.detail(trace)
368
+ spans = detail.delete(:spans)
369
+ failed_spans = spans.select { |span| span[:error] }.map do |span|
370
+ span.slice(:span_id, :name, :type).merge(message: span.dig(:attributes, "error.message"))
371
+ end
372
+
373
+ payload = detail.merge(
374
+ tool_calls: trace.tool_usage,
375
+ failed_spans: failed_spans,
376
+ spans: spans.first(MAX_TRACE_SPANS),
377
+ spans_total: spans.size,
378
+ spans_omitted: [ spans.size - MAX_TRACE_SPANS, 0 ].max
379
+ )
380
+ PayloadBounds.bound(payload, max_string: MAX_STRING, max_items: MAX_TRACE_SPANS)
381
+ end
382
+
383
+ # `trace_id` names the result's telemetry trace for traces_get, and is
384
+ # nil when telemetry recorded none for the replay.
385
+ def result_row(result, recorded_trace_ids)
386
+ {
387
+ id: result.id,
388
+ scenario_key: result.evaluated_scenario["key"],
389
+ group: result.evaluated_scenario["group"],
390
+ prompt: result.evaluated_scenario["prompt"],
391
+ model: result.model,
392
+ status: result.status,
393
+ score: result.score,
394
+ fault: result.fault,
395
+ recommendation: result.recommendation,
396
+ diagnosis: result.evaluation_diagnosis,
397
+ output: result.output,
398
+ tool_calls: result.tool_calls,
399
+ error_message: result.error_message,
400
+ duration_ms: result.duration_ms,
401
+ input_tokens: result.input_tokens,
402
+ output_tokens: result.output_tokens,
403
+ cost: result.cost&.to_f,
404
+ agent_run_id: result.agent_run_id,
405
+ trace_id: recorded_trace_ids.include?(result.agent_run&.trace_id) ? result.agent_run.trace_id : nil
406
+ }
407
+ end
408
+
409
+ # The subset of +trace_ids+ the owner's telemetry holds.
410
+ def recorded_trace_ids(trace_ids)
411
+ return Set.new if trace_ids.empty?
412
+
413
+ owned_traces.where(trace_id: trace_ids).pluck(:trace_id).to_set
414
+ end
415
+
416
+ # The run's results keyed by [scenario key, model].
417
+ def comparison_rows(run)
418
+ run.scenario_results.includes(:scenario).index_by { |result| [ result.evaluated_scenario["key"], result.model ] }
419
+ end
420
+
421
+ # One of: removed, added_pass, added_fail, unchanged_pass, regressed,
422
+ # fixed, still_failing.
423
+ def comparison_change(before, after)
424
+ return "removed" if after.nil?
425
+ return after.passed? ? "added_pass" : "added_fail" if before.nil?
426
+ return after.passed? ? "unchanged_pass" : "regressed" if before.passed?
427
+
428
+ after.passed? ? "fixed" : "still_failing"
429
+ end
430
+
431
+ # Fix item links at the facade's absolute mount, since a client has no
432
+ # dashboard page to resolve a relative path against.
433
+ def dashboard_links(run)
434
+ run.report_links(mount: "#{request.base_url}#{request.script_name}")
435
+ end
436
+
437
+ def dashboard_evaluations
438
+ Evaluation.joins(:agent).where(agent: owner_agents)
439
+ end
440
+
441
+ def find_tool_evaluation!
442
+ id = tool_argument(:evaluation_id)
443
+ raise MCPController::McpError.new("Missing required argument: evaluation_id", MCPController::JSONRPC_INVALID_PARAMS) if id.blank?
444
+
445
+ dashboard_evaluations.find_by(id: id.to_s) or raise ToolError, "No evaluation #{id.to_s.truncate(32)} was found"
446
+ end
447
+
448
+ # The evaluation's run +id+ names, or its latest run when +id+ is blank.
449
+ def find_tool_run!(evaluation, id, label:)
450
+ if id.blank?
451
+ evaluation.evaluation_runs.recent.first or raise ToolError, "Evaluation #{evaluation.id} has not been run yet"
452
+ else
453
+ evaluation.evaluation_runs.find_by(id: id.to_s) or
454
+ raise ToolError, "No run #{id.to_s.truncate(32)} of evaluation #{evaluation.id} was found (#{label})"
455
+ end
456
+ end
457
+
458
+ def resolve_tool_agent!(value)
459
+ agents = owner_agents
460
+ agents.find_by(slug: value.to_s) || (value.to_s.match?(/\A\d+\z/) && agents.find_by(id: value.to_s)) or
461
+ raise ToolError, "No agent #{value.to_s.truncate(64)} was found"
462
+ end
463
+
464
+ def tool_arguments
465
+ @tool_arguments ||= begin
466
+ arguments = params.dig(:params, :arguments)
467
+ arguments.is_a?(ActionController::Parameters) ? arguments : ActionController::Parameters.new({})
468
+ end
469
+ end
470
+
471
+ def tool_argument(name)
472
+ tool_arguments[name]
473
+ end
474
+
475
+ # An integer argument clamped into [min, max], or +default+ when absent
476
+ # or not a number.
477
+ def integer_argument(name, default:, min: nil, max: nil)
478
+ raw = tool_argument(name)
479
+ value = raw.is_a?(Integer) || raw.to_s.match?(/\A-?\d+\z/) ? raw.to_i : default
480
+ return value if value.nil?
481
+
482
+ value = [ value, min ].max if min
483
+ value = [ value, max ].min if max
484
+ value
485
+ end
486
+
487
+ def boolean_argument(name)
488
+ ActiveModel::Type::Boolean.new.cast(tool_argument(name)) == true
489
+ end
490
+
491
+ # Every credential the owner holds, masked out of each tool's output:
492
+ # this key's token, provider keys, the GitHub token, and each checkout
493
+ # sandbox's runtime token. None of them belongs in these payloads; this
494
+ # keeps one that leaked into a recorded output or a trace from being
495
+ # handed on. A failed lookup masks nothing rather than failing the call.
496
+ def dashboard_tool_secrets
497
+ @dashboard_tool_secrets ||= [
498
+ @api_key&.token,
499
+ *owned(ProviderKey).limit(SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
500
+ *owned(GithubConnection).limit(SECRET_LOOKUP_LIMIT).pluck(:access_token),
501
+ *owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(SECRET_LOOKUP_LIMIT).pluck(:runtime_mcp_token)
502
+ ].compact
503
+ rescue StandardError => e
504
+ Rails.logger.warn("[ActionAgent] MCP secret lookup failed: #{e.class}: #{e.message}")
505
+ @dashboard_tool_secrets = [ @api_key&.token ].compact
506
+ end
507
+ end
508
+ end
509
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # A run against a checkout sandbox the agent does not name: `sandbox_id`
6
+ # on an evaluation run or a runner run makes that one run reach the
7
+ # sandbox's app runtime, as if the agent listed "sandbox:<session_id>" in
8
+ # mcp_servers, without saving it there. It is how a change being tried in
9
+ # a checkout is evaluated before anyone edits the agent.
10
+ #
11
+ # The sandbox has to be the caller's (found the way CodeSessionsController
12
+ # finds one: owned, and in the current account), a checkout, live, and
13
+ # owned by the agent's owner as well, since the dispatcher resolves the
14
+ # runtime among that owner's sessions only (see
15
+ # SandboxSession.runtime_server_entry). Anything else is refused with a
16
+ # 422 saying which. Only the key and the checkout it names are passed on
17
+ # and recorded; the runtime's token stays on the session.
18
+ module RunSandbox
19
+ extend ActiveSupport::Concern
20
+
21
+ class Refused < StandardError; end
22
+
23
+ included do
24
+ rescue_from Refused do |error|
25
+ render json: { error: error.message, code: "sandbox_refused" }, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # The caller's live checkout sandbox that +sandbox_id+ names for a run
32
+ # of +agent+, or nil when none was asked for. Raises Refused.
33
+ #
34
+ # @return [SandboxSession, nil]
35
+ def run_sandbox_for(agent, sandbox_id)
36
+ return nil if sandbox_id.blank?
37
+ raise Refused, "sandbox_id must be a sandbox's session id" unless sandbox_id.is_a?(String)
38
+
39
+ scope = owned(SandboxSession)
40
+ scope = scope.where(account_id: current_account.id) if current_account
41
+ # Unknown and someone else's read the same: whether a session id
42
+ # exists is not the caller's to learn.
43
+ sandbox = scope.find_by(session_id: sandbox_id) or
44
+ raise Refused, "No sandbox #{sandbox_id.truncate(64)} of yours was found"
45
+ label = "Sandbox #{sandbox.session_id}"
46
+
47
+ unless sandbox.app_runtime?
48
+ raise Refused, "#{label} is a #{sandbox.sandbox_type} sandbox; only a checkout (app_runtime) sandbox " \
49
+ "serves an app's tools"
50
+ end
51
+ # Ready (or running a task), and serving: a sandbox still booting
52
+ # can already carry an endpoint that is not answering yet.
53
+ if !(sandbox.ready? || sandbox.running?) || sandbox.runtime_server_entry.nil?
54
+ state = sandbox.ready? && !sandbox.active? ? "expired" : sandbox.status
55
+ raise Refused, "#{label} is #{state}; run against a checkout sandbox once it is ready"
56
+ end
57
+ unless SandboxSession.runtime_server_entry(sandbox.runtime_server_key, owner: agent.owner)
58
+ raise Refused, "#{label} does not belong to the owner of #{agent.name}, so this agent's runs cannot reach it"
59
+ end
60
+
61
+ sandbox
62
+ end
63
+ end
64
+ end
65
+ end