actionagent 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +48 -44
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
  6. data/app/controllers/action_agent/api/agents_controller.rb +76 -48
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
  10. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  11. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  12. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  13. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  14. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  15. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  16. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  17. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  18. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  19. data/app/controllers/action_agent/application_controller.rb +25 -2
  20. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  21. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  22. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  23. data/app/jobs/action_agent/application_job.rb +7 -3
  24. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  25. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  26. data/app/models/action_agent/agent.rb +14 -5
  27. data/app/models/action_agent/agent_template.rb +22 -7
  28. data/app/models/action_agent/evaluation.rb +64 -4
  29. data/app/models/action_agent/evaluation_run.rb +182 -2
  30. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  31. data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
  32. data/app/models/action_agent/recording_action.rb +11 -7
  33. data/app/models/action_agent/sandbox_session.rb +1 -1
  34. data/app/models/action_agent/session_recording.rb +31 -8
  35. data/app/models/action_agent/telemetry_trace.rb +110 -2
  36. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  37. data/app/models/concerns/action_agent/ownable.rb +15 -2
  38. data/app/queries/action_agent/metrics_report.rb +498 -0
  39. data/app/services/action_agent/agent_toolbox.rb +4 -4
  40. data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
  41. data/app/services/action_agent/mcp_catalog.rb +46 -8
  42. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  43. data/app/services/action_agent/playwright_mcp_client.rb +6 -6
  44. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  45. data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
  46. data/app/services/action_agent/tool_discovery.rb +22 -8
  47. data/config/routes.rb +25 -2
  48. data/lib/action_agent/engine.rb +101 -19
  49. data/lib/action_agent/version.rb +1 -1
  50. data/lib/action_agent.rb +72 -6
  51. data/lib/generators/action_agent/install_generator.rb +20 -7
  52. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  53. data/lib/tasks/action_agent.rake +9 -0
  54. metadata +19 -6
@@ -63,13 +63,7 @@ module ActionAgent
63
63
  def interaction_messages(run)
64
64
  return [] if run.trace_id.blank?
65
65
 
66
- context = AgentContext.for_agents(Agent.where(id: run.agent_id)).order(created_at: :desc).first
67
- return [] unless context
68
-
69
- messages = context.messages.chronological.to_a
70
- start_index = messages.index do |message|
71
- message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id
72
- end
66
+ messages, start_index = run_slice_start(run)
73
67
  return [] unless start_index
74
68
 
75
69
  slice = [ messages[start_index] ]
@@ -93,6 +87,30 @@ module ActionAgent
93
87
  serialized
94
88
  end
95
89
 
90
+ # solid_agent keys the persisted context by action_name, so each action
91
+ # has its own stream. The run's own action is searched first; a run
92
+ # whose action is blank or legacy (or whose trace landed in another
93
+ # stream) falls back to every context of the agent, newest first.
94
+ # Picking only the newest context regardless of action left the
95
+ # conversation empty for every run of any other action.
96
+ def run_slice_start(run)
97
+ contexts = AgentContext.for_agents(Agent.where(id: run.agent_id)).order(created_at: :desc)
98
+ action = run.action_name.presence || run.output_metadata&.dig("action").presence || Agent::DEFAULT_ACTION
99
+
100
+ candidates = contexts.for_action(action).to_a
101
+ candidates += contexts.to_a.reject { |context| candidates.include?(context) }
102
+
103
+ candidates.each do |context|
104
+ messages = context.messages.chronological.to_a
105
+ start_index = messages.index do |message|
106
+ message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id
107
+ end
108
+ return [ messages, start_index ] if start_index
109
+ end
110
+
111
+ [ [], nil ]
112
+ end
113
+
96
114
  def run_json(run, include_agent: false)
97
115
  json = {
98
116
  id: run.id,
@@ -3,6 +3,8 @@
3
3
  module ActionAgent
4
4
  module Api
5
5
  class AgentsController < BaseController
6
+ include AgentSerialization
7
+
6
8
  # Ranking for the agent cards. Every dimension except "recent" reads the
7
9
  # scorecard, which is computed in Ruby over both execution sources, so
8
10
  # the ordering is applied there rather than in the SQL scope.
@@ -18,6 +20,7 @@ module ActionAgent
18
20
  before_action :set_agent, only: [ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics ]
19
21
  before_action :require_execution_enabled!, only: [ :execute, :test ]
20
22
  before_action :require_owner!, only: [ :execute, :test ]
23
+ before_action :require_executable_agent!, only: [ :execute, :test ]
21
24
  before_action :enforce_execution_quota!, only: [ :execute, :test ]
22
25
 
23
26
  # GET /api/agents
@@ -199,37 +202,82 @@ module ActionAgent
199
202
  end
200
203
 
201
204
  # GET /api/agents/:id/analytics
205
+ #
206
+ # Every execution of this agent, whoever ran it — the same merged model
207
+ # the runs list and the scorecard use. Agents observed from telemetry
208
+ # have no AgentRun rows at all, so a runs-only aggregate showed them
209
+ # with all-zero metrics beside a card and a runs list reporting real
210
+ # traffic.
202
211
  def analytics
203
212
  days = (params[:days] || 30).to_i
204
213
  start_date = days.days.ago.beginning_of_day
205
214
 
206
215
  runs = @agent.agent_runs.where("created_at >= ?", start_date)
216
+ traces = AgentExecutions.unclaimed_traces([ @agent.id ], since: start_date, owner: current_owner)
217
+ traces_table = ActionAgent.trace_model.table_name
218
+ trace_tokens_sql = Arel.sql(
219
+ "COALESCE(#{traces_table}.total_input_tokens, 0) + COALESCE(#{traces_table}.total_output_tokens, 0) + " \
220
+ "COALESCE(#{traces_table}.total_thinking_tokens, 0)"
221
+ )
207
222
 
208
- # Calculate stats
209
- total_runs = runs.count
223
+ # Dashboard runs
224
+ run_count = runs.count
210
225
  completed_runs = runs.where(status: :complete).count
211
226
  failed_runs = runs.where(status: :failed).count
212
- avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
213
- total_tokens = runs.sum(:total_tokens)
227
+ timed_runs = runs.where.not(duration_ms: nil)
228
+ run_tokens = runs.sum(:total_tokens)
229
+
230
+ # Reported executions
231
+ trace_count = traces.count
232
+ trace_failures = traces.where(status: "ERROR").count
233
+ timed_traces = traces.where.not(total_duration_ms: nil)
234
+ trace_tokens = traces.sum(trace_tokens_sql)
235
+
236
+ total_runs = run_count + trace_count
237
+ completed_runs += trace_count - trace_failures
238
+ failed_runs += trace_failures
239
+ total_tokens = run_tokens + trace_tokens
214
240
  avg_tokens = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
215
241
 
216
- # Runs by day
217
- runs_by_day = runs.group("DATE(created_at)")
242
+ # Weighted across both sources, so one side's long tail counts for
243
+ # what it is.
244
+ timed_total = timed_runs.count + timed_traces.count
245
+ avg_duration = if timed_total.positive?
246
+ (timed_runs.sum(:duration_ms) + timed_traces.sum(:total_duration_ms)).to_f / timed_total
247
+ else
248
+ 0
249
+ end.round
250
+
251
+ # Runs by day, zero-filled across the window (see AnalyticsController)
252
+ by_day = Hash.new { |hash, date| hash[date] = { date: date, count: 0, tokens: 0 } }
253
+ runs.group("DATE(created_at)")
218
254
  .select("DATE(created_at) as date, COUNT(*) as count, SUM(total_tokens) as tokens")
219
- .order("date")
220
- .map { |r| { date: r.date.to_s, count: r.count, tokens: r.tokens || 0 } }
255
+ .each { |r| by_day[r.date.to_s].merge!(count: r.count, tokens: r.tokens || 0) }
256
+ trace_day_sql = Arel.sql("DATE(#{traces_table}.timestamp)")
257
+ trace_counts = traces.group(trace_day_sql).count
258
+ trace_token_sums = traces.group(trace_day_sql).sum(trace_tokens_sql)
259
+ trace_counts.each do |date, count|
260
+ bucket = by_day[date.to_s]
261
+ bucket[:count] += count
262
+ bucket[:tokens] += trace_token_sums[date].to_i
263
+ end
264
+ runs_by_day = (start_date.to_date..Date.current).map { |day| by_day[day.to_s] }
221
265
 
222
- # Status breakdown
266
+ # Status breakdown. A reported execution is complete unless its trace
267
+ # errored; those are the only two states a trace can be in.
223
268
  status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
269
+ status_breakdown["complete"] = status_breakdown.fetch("complete", 0) + (trace_count - trace_failures)
270
+ status_breakdown["failed"] = status_breakdown.fetch("failed", 0) + trace_failures
271
+ status_breakdown.delete_if { |_status, count| count.zero? }
224
272
 
225
- # Recent errors
273
+ # Recent errors, from both sources
226
274
  recent_errors = runs.failed_runs.recent.limit(5).map do |run|
227
- {
228
- id: run.id,
229
- error: run.error_message&.truncate(200),
230
- created_at: run.created_at
231
- }
275
+ { id: run.id, source: "dashboard", error: run.error_message&.truncate(200), created_at: run.created_at }
276
+ end
277
+ recent_errors += traces.where(status: "ERROR").order(timestamp: :desc).limit(5).map do |trace|
278
+ { id: "trace-#{trace.id}", source: "reported", error: trace.error_message&.truncate(200), created_at: trace.timestamp }
232
279
  end
280
+ recent_errors = recent_errors.sort_by { |row| row[:created_at] }.reverse.first(5)
233
281
 
234
282
  render json: {
235
283
  period_days: days,
@@ -265,6 +313,19 @@ module ActionAgent
265
313
 
266
314
  private
267
315
 
316
+ # Observed agents were discovered from reported telemetry; the platform
317
+ # has no configuration to run them with (a placeholder model, no
318
+ # instructions), so executing one only manufactured a failed run that
319
+ # was then blended into the clean scorecard its telemetry had built.
320
+ # Duplicating an observed agent yields a draft that can be run.
321
+ def require_executable_agent!
322
+ return unless @agent.observed?
323
+
324
+ render json: {
325
+ error: "Observed agents are read-only — duplicate this agent to create an executable copy"
326
+ }, status: :unprocessable_entity
327
+ end
328
+
268
329
  def list_sort(requested)
269
330
  LIST_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_LIST_SORT
270
331
  end
@@ -323,39 +384,6 @@ module ActionAgent
323
384
  )
324
385
  end
325
386
 
326
- def agent_json(agent, include_details: false)
327
- json = {
328
- id: agent.id,
329
- name: agent.name,
330
- slug: agent.slug,
331
- description: agent.description,
332
- provider: agent.provider,
333
- model: agent.model,
334
- status: agent.status,
335
- preset_type: agent.preset_type,
336
- appearance: agent.appearance,
337
- version_count: agent.version_count,
338
- created_at: agent.created_at,
339
- updated_at: agent.updated_at
340
- }
341
-
342
- if include_details
343
- json.merge!(
344
- instructions: agent.instructions,
345
- action_prompts: agent.action_prompts,
346
- instruction_sets: agent.instruction_sets,
347
- tools: agent.tools,
348
- mcp_servers: agent.mcp_servers,
349
- model_config: agent.model_config,
350
- response_format: agent.response_format,
351
- agent_class_name: agent.agent_class_name,
352
- telemetry_agent_class: agent.telemetry_agent_class
353
- )
354
- end
355
-
356
- json
357
- end
358
-
359
387
  def version_json(version, include_diff: false)
360
388
  json = {
361
389
  id: version.id,
@@ -32,17 +32,26 @@ module ActionAgent
32
32
  avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
33
33
  avg_tokens_per_run = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
34
34
 
35
- # Runs over time
36
- runs_by_day = runs.group("DATE(#{runs_table}.created_at)")
37
- .select("DATE(#{runs_table}.created_at) as date, COUNT(*) as count")
38
- .order("date")
39
- .map { |r| { date: r.date.to_s, count: r.count } }
35
+ # Runs over time. Zero-filled across the window: grouping only
36
+ # returns days that have rows, and the chart draws whatever it gets
37
+ # as adjacent bars, so a 30-day window with runs on two far-apart
38
+ # days rendered as two neighbouring bars.
39
+ runs_by_day = zero_filled_days(
40
+ start_date,
41
+ runs.group("DATE(#{runs_table}.created_at)")
42
+ .select("DATE(#{runs_table}.created_at) as date, COUNT(*) as count")
43
+ .map { |r| { date: r.date.to_s, count: r.count } },
44
+ count: 0
45
+ )
40
46
 
41
47
  # Token usage over time
42
- tokens_by_day = runs.group("DATE(#{runs_table}.created_at)")
43
- .select("DATE(#{runs_table}.created_at) as date, SUM(total_tokens) as tokens")
44
- .order("date")
45
- .map { |r| { date: r.date.to_s, tokens: r.tokens || 0 } }
48
+ tokens_by_day = zero_filled_days(
49
+ start_date,
50
+ runs.group("DATE(#{runs_table}.created_at)")
51
+ .select("DATE(#{runs_table}.created_at) as date, SUM(total_tokens) as tokens")
52
+ .map { |r| { date: r.date.to_s, tokens: r.tokens || 0 } },
53
+ tokens: 0
54
+ )
46
55
 
47
56
  # Top agents by usage
48
57
  top_agents = agents.joins(:agent_runs)
@@ -89,6 +98,19 @@ module ActionAgent
89
98
  provider_breakdown: provider_breakdown
90
99
  }
91
100
  end
101
+
102
+ private
103
+
104
+ # One entry per calendar day from +start_date+ through today, in
105
+ # order, with +defaults+ for the days the grouped +rows+ did not
106
+ # mention. Keyed on the ISO date string so it reads the same whether
107
+ # the adapter returns DATE() as a Date or a String.
108
+ def zero_filled_days(start_date, rows, **defaults)
109
+ by_date = rows.index_by { |row| row[:date] }
110
+ (start_date.to_date..Date.current).map do |day|
111
+ by_date[day.to_s] || defaults.merge(date: day.to_s)
112
+ end
113
+ end
92
114
  end
93
115
  end
94
116
  end
@@ -20,9 +20,25 @@ module ActionAgent
20
20
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
21
21
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
22
22
  rescue_from ActionController::ParameterMissing, with: :bad_request
23
+ rescue_from ActiveRecord::Encryption::Errors::Configuration, with: :encryption_unconfigured
23
24
 
24
25
  private
25
26
 
27
+ # API keys and provider credentials are encrypted at rest, which needs
28
+ # Active Record Encryption keys. The engine derives fallback keys when
29
+ # the host set none (see Engine's action_agent.active_record_encryption
30
+ # initializer), so this is only reached when that fallback was disabled
31
+ # or the host's own keys are broken — in which case the operator gets
32
+ # told what to do rather than an HTML 500 the Settings view collapses
33
+ # into "Could not create the API key."
34
+ def encryption_unconfigured(exception)
35
+ render json: {
36
+ error: "Active Record encryption is not configured (#{exception.message}). " \
37
+ "Run `rails db:encryption:init` and add the keys to your credentials, " \
38
+ "or set ActionAgent.encrypt_credentials = false to store credentials unencrypted."
39
+ }, status: :service_unavailable
40
+ end
41
+
26
42
  # Scopes +relation+ to the caller, following the model's own
27
43
  # declaration. Unowned models (a single-user install, or a model that
28
44
  # nothing owns) come back unfiltered.
@@ -4,8 +4,20 @@ module ActionAgent
4
4
  module Api
5
5
  # CRUD + execution for agent evaluations, backing the dashboard
6
6
  # Evaluations view. Scoped to the current user's agents.
7
+ #
8
+ # An evaluation created with scenarios (a pasted list of user messages)
9
+ # is a scenario suite: runs replay the scenarios through the agent rather
10
+ # than sampling recorded generations, and can be narrowed to a group, to
11
+ # specific scenarios, or to specific models.
7
12
  class EvaluationsController < BaseController
8
13
  before_action :require_owner!
14
+ # A scenario suite replays its prompts through the provider, so creating
15
+ # one that runs, or running one, executes the agent and is gated the way
16
+ # AgentsController#execute is: the dashboard's execution switch, no
17
+ # observed (read-only) agents, and the owner's execution quota. Each
18
+ # replay then counts as one execution (ScenarioEvaluationRunner#replay).
19
+ before_action :require_execution_enabled!, :require_executable_scenario_agent!, :enforce_execution_quota!,
20
+ only: [ :create, :run ], if: :replays_scenarios?
9
21
 
10
22
  # Default criteria used when none are supplied — all rule-based, so a
11
23
  # new evaluation produces real scores without provider credentials.
@@ -25,7 +37,7 @@ module ActionAgent
25
37
  def index
26
38
  scope = evaluations_scope
27
39
  scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
28
- evaluations = scope.includes(:agent, :evaluation_runs).recent.limit(50)
40
+ evaluations = scope.includes(:agent, :evaluation_runs, :scenarios).recent.limit(50)
29
41
 
30
42
  render json: { evaluations: evaluations.map { |evaluation| serialize(evaluation) } }
31
43
  end
@@ -36,6 +48,7 @@ module ActionAgent
36
48
 
37
49
  render json: {
38
50
  evaluation: serialize(evaluation).merge(
51
+ scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
39
52
  runs: evaluation.evaluation_runs.recent.limit(20).map { |run| serialize_run(run) }
40
53
  )
41
54
  }
@@ -43,11 +56,12 @@ module ActionAgent
43
56
 
44
57
  # POST /api/evaluations
45
58
  def create
46
- agent = owner_agents.find(params.require(:evaluation)[:agent_id])
59
+ agent = requested_agent
47
60
 
48
61
  judge_kind = evaluation_params[:judge_kind].presence || "rules"
49
62
  config = {}
50
63
  config["compare_models"] = compare_models_param if compare_models_param.any?
64
+ scenarios = scenario_attributes
51
65
 
52
66
  evaluation = agent.evaluations.new(
53
67
  name: evaluation_params[:name],
@@ -59,9 +73,15 @@ module ActionAgent
59
73
  criteria: judge_kind == "judge_defined" ? explicit_criteria : normalized_criteria,
60
74
  config: config
61
75
  )
76
+ scenarios.each_with_index do |attrs, index|
77
+ evaluation.scenarios.build(
78
+ key: attrs["key"], prompt: attrs["prompt"], group: attrs["group"], notes: attrs["notes"],
79
+ expectations: attrs["expectations"] || {}, position: attrs.fetch("position", index)
80
+ )
81
+ end
62
82
 
63
83
  if evaluation.save
64
- evaluation.run!
84
+ start_run(evaluation, selection_params) if run_requested?
65
85
  render json: { evaluation: serialize(evaluation.reload) }, status: :created
66
86
  else
67
87
  render json: { errors: evaluation.errors.full_messages }, status: :unprocessable_entity
@@ -69,13 +89,94 @@ module ActionAgent
69
89
  end
70
90
 
71
91
  # POST /api/evaluations/:id/run
92
+ # A scenario suite accepts a selection: scenario_ids[], keys[], group,
93
+ # models[] (or a comma-separated `models` string).
72
94
  def run
73
- evaluation = evaluations_scope.find(params[:id])
74
- run = evaluation.run!
95
+ evaluation = current_evaluation
96
+ run = start_run(evaluation, selection_params)
75
97
 
76
98
  render json: { evaluation: serialize(evaluation.reload), run: serialize_run(run) }
77
99
  end
78
100
 
101
+ # GET /api/evaluations/:id/runs/:run_id
102
+ # One run in full: its per-scenario, per-model results alongside the
103
+ # scenarios, so the matrix and every answer can be rendered, and its
104
+ # fix items — the faults grouped with the tools, MCP server and
105
+ # dashboard action that address each. Fix item paths are relative to
106
+ # the mount: the React app resolves them itself (dashboardPath).
107
+ def show_run
108
+ evaluation = evaluations_scope.find(params[:id])
109
+ run = evaluation.evaluation_runs.find(params[:run_id])
110
+ results = run.scenario_results.includes(:scenario).joins(:scenario)
111
+ .order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
112
+
113
+ render json: {
114
+ evaluation: serialize(evaluation),
115
+ run: serialize_run(run).merge(results: results.map(&:as_json_summary), fix_items: safe_fix_items(run))
116
+ }
117
+ end
118
+
119
+ # GET /api/evaluations/:id/scenarios
120
+ def scenarios
121
+ evaluation = evaluations_scope.find(params[:id])
122
+
123
+ render json: {
124
+ scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
125
+ groups: evaluation.scenario_groups
126
+ }
127
+ end
128
+
129
+ # PUT /api/evaluations/:id/scenarios
130
+ # Replaces the suite from pasted text (`scenarios_text`) or a list
131
+ # (`scenarios`). Scenarios whose key survives keep their results.
132
+ def replace_scenarios
133
+ evaluation = evaluations_scope.find(params[:id])
134
+ attributes = scenario_attributes
135
+ return render json: { errors: [ "No scenarios found in the pasted text" ] }, status: :unprocessable_entity if attributes.empty?
136
+
137
+ evaluation.replace_scenarios!(attributes)
138
+
139
+ render json: {
140
+ evaluation: serialize(evaluation.reload),
141
+ scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
142
+ groups: evaluation.scenario_groups
143
+ }
144
+ end
145
+
146
+ # PATCH /api/evaluations/:id/scenarios/:scenario_id
147
+ def update_scenario
148
+ evaluation = evaluations_scope.find(params[:id])
149
+ scenario = evaluation.scenarios.find(params[:scenario_id])
150
+ scenario.update!(scenario_params)
151
+
152
+ render json: { scenario: scenario.as_json_summary }
153
+ end
154
+
155
+ # DELETE /api/evaluations/:id/scenarios/:scenario_id
156
+ def destroy_scenario
157
+ evaluation = evaluations_scope.find(params[:id])
158
+ evaluation.scenarios.find(params[:scenario_id]).destroy!
159
+ head :no_content
160
+ end
161
+
162
+ # GET /api/evaluations/:id/runs/:run_id/report?theme=dark
163
+ #
164
+ # The run as the framework's self-contained HTML report page — the
165
+ # in-dashboard view and, because the page is a single file, the export.
166
+ # `theme` (light|dark) pins the palette to the dashboard's; without it
167
+ # the page follows the viewer's own preference. The page is served
168
+ # outside the React app, so its fix item actions link at the absolute
169
+ # mount path (request.script_name) rather than relative to it.
170
+ def run_report
171
+ evaluation = evaluations_scope.find(params[:id])
172
+ run = evaluation.evaluation_runs.find(params[:run_id])
173
+ raise ActiveRecord::RecordNotFound unless evaluation.scenario_suite?
174
+
175
+ report = run.to_report(links: run.report_links(mount: request.script_name))
176
+
177
+ render html: report.to_html(theme: params[:theme]).html_safe, layout: false
178
+ end
179
+
79
180
  # DELETE /api/evaluations/:id
80
181
  def destroy
81
182
  evaluations_scope.find(params[:id]).destroy!
@@ -84,14 +185,111 @@ module ActionAgent
84
185
 
85
186
  private
86
187
 
188
+ # A scenario suite replays through the provider once per scenario and
189
+ # model, so it runs in the background; a generation-sampling evaluation
190
+ # scores recorded data and finishes inline.
191
+ def start_run(evaluation, selection)
192
+ return evaluation.run_later!(**selection) if evaluation.scenario_suite?
193
+
194
+ # EvaluationRunnerService marks the run failed with the error message
195
+ # and then re-raises. Letting that escape returned an HTML 500 for a
196
+ # request that had already persisted the evaluation and its failed
197
+ # run: the client saw a JSON parse error, the form stayed open, and a
198
+ # resubmit failed on the now-taken name. The failure is on the run
199
+ # record, which is what the response carries.
200
+ evaluation.run!
201
+ rescue StandardError => e
202
+ Rails.logger.warn(
203
+ "[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
204
+ )
205
+ # The service records the failure before re-raising; a failure that
206
+ # predates the run record (creating it, say) is recorded here so the
207
+ # response always carries one.
208
+ evaluation.evaluation_runs.recent.first ||
209
+ evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
210
+ end
211
+
87
212
  def evaluations_scope
88
213
  Evaluation.joins(:agent).where(agent: owner_agents)
89
214
  end
90
215
 
216
+ def current_evaluation
217
+ @current_evaluation ||= evaluations_scope.find(params[:id])
218
+ end
219
+
220
+ def requested_agent
221
+ @requested_agent ||= owner_agents.find(params.require(:evaluation)[:agent_id])
222
+ end
223
+
224
+ # create runs the new evaluation unless told not to.
225
+ def run_requested?
226
+ run = params.require(:evaluation)[:run]
227
+ run != false && run != "false"
228
+ end
229
+
230
+ # Whether this request replays scenarios through the agent: running a
231
+ # scenario suite, or creating an evaluation with scenarios that runs.
232
+ def replays_scenarios?
233
+ case action_name
234
+ when "run" then current_evaluation.scenario_suite?
235
+ when "create" then run_requested? && scenario_attributes.any?
236
+ else false
237
+ end
238
+ end
239
+
240
+ # The refusal AgentsController gives an observed agent: it was
241
+ # discovered from telemetry and has nothing to execute.
242
+ def require_executable_scenario_agent!
243
+ agent = action_name == "create" ? requested_agent : current_evaluation.agent
244
+ return unless agent.observed?
245
+
246
+ render json: {
247
+ error: "Observed agents are read-only — duplicate this agent to create an executable copy"
248
+ }, status: :unprocessable_entity
249
+ end
250
+
91
251
  def evaluation_params
92
252
  params.require(:evaluation).permit(:agent_id, :name, :judge_kind, :judge_model, :sample_size)
93
253
  end
94
254
 
255
+ def scenario_params
256
+ params.require(:scenario).permit(:prompt, :group, :notes, :enabled, :key, expectations: {})
257
+ end
258
+
259
+ # scenario_ids, keys, group and models narrow a scenario run. `models`
260
+ # may arrive as an array or as the comma-separated field the form posts.
261
+ def selection_params
262
+ source = params[:evaluation].is_a?(ActionController::Parameters) && params[:evaluation].key?(:selection) ? params[:evaluation][:selection] : params
263
+ models = source[:models]
264
+ models = models.to_s.split(",") unless models.is_a?(Array)
265
+
266
+ {
267
+ scenario_ids: Array(source[:scenario_ids]).map(&:to_s).reject(&:blank?),
268
+ keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
269
+ group: source[:group].to_s.presence,
270
+ models: models.map(&:to_s).map(&:strip).reject(&:blank?)
271
+ }.compact_blank
272
+ end
273
+
274
+ # Scenarios from the request: a pasted text block, a list of objects, or
275
+ # nothing (a generation-sampling evaluation).
276
+ def scenario_attributes
277
+ @scenario_attributes ||= begin
278
+ source = params[:evaluation].presence || params
279
+ text = source[:scenarios_text].to_s
280
+ list = source[:scenarios]
281
+
282
+ if list.present?
283
+ list = list.to_unsafe_h.values if list.is_a?(ActionController::Parameters)
284
+ ActiveAgent::Evals::ScenarioParser.parse(Array(list).map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }.to_json)
285
+ elsif text.present?
286
+ ActiveAgent::Evals::ScenarioParser.parse(text)
287
+ else
288
+ []
289
+ end
290
+ end
291
+ end
292
+
95
293
  def normalized_criteria
96
294
  explicit_criteria.presence || DEFAULT_CRITERIA.deep_dup
97
295
  end
@@ -109,7 +307,9 @@ module ActionAgent
109
307
  end
110
308
 
111
309
  def compare_models_param
112
- Array(params[:evaluation][:compare_models]).map(&:to_s).reject(&:blank?)
310
+ models = params[:evaluation][:compare_models]
311
+ models = models.to_s.split(",") unless models.is_a?(Array)
312
+ models.map(&:to_s).map(&:strip).reject(&:blank?)
113
313
  end
114
314
 
115
315
  def serialize(evaluation)
@@ -125,6 +325,9 @@ module ActionAgent
125
325
  compare_models: evaluation.compare_models,
126
326
  config: evaluation.config,
127
327
  sample_size: evaluation.sample_size,
328
+ scenario_suite: evaluation.scenario_suite?,
329
+ scenario_count: evaluation.scenarios.size,
330
+ scenario_groups: evaluation.scenario_suite? ? evaluation.scenario_groups : [],
128
331
  created_at: evaluation.created_at.iso8601,
129
332
  latest_run: latest ? serialize_run(latest) : nil
130
333
  }
@@ -135,14 +338,41 @@ module ActionAgent
135
338
  id: run.id,
136
339
  status: run.status,
137
340
  scores: run.scores,
138
- average_score: run.average_score,
341
+ selection: run.selection,
342
+ models: run.models,
343
+ average_score: safe_average_score(run),
139
344
  samples_evaluated: run.samples_evaluated,
140
345
  samples_passed: run.samples_passed,
346
+ usage: run.usage,
141
347
  error_message: run.error_message,
142
348
  completed_at: run.completed_at&.iso8601,
143
349
  created_at: run.created_at.iso8601
144
350
  }
145
351
  end
352
+
353
+ # The fix items are derived from every persisted result's diagnosis,
354
+ # which older runs recorded in earlier shapes; a run they cannot be
355
+ # built for still serves its results rather than 500-ing the panel.
356
+ def safe_fix_items(run)
357
+ run.fix_items
358
+ rescue StandardError => e
359
+ Rails.logger.warn(
360
+ "[ActionAgent] evaluation run #{run.id} fix_items failed: #{e.class}: #{e.message}"
361
+ )
362
+ []
363
+ end
364
+
365
+ # index serializes the latest run of every listed evaluation, so an
366
+ # unaverageable scores payload used to 500 the entire Evaluations page
367
+ # instead of degrading that one run's headline number.
368
+ def safe_average_score(run)
369
+ run.average_score
370
+ rescue StandardError => e
371
+ Rails.logger.warn(
372
+ "[ActionAgent] evaluation run #{run.id} average_score failed: #{e.class}: #{e.message}"
373
+ )
374
+ nil
375
+ end
146
376
  end
147
377
  end
148
378
  end
@@ -19,10 +19,10 @@ module ActionAgent
19
19
  # Connect from an MCP client with:
20
20
  # { "type": "http", "url": "https://activeagents.ai/mcp",
21
21
  # "headers": { "Authorization": "Bearer aa_..." } }
22
- class McpController < BaseController
22
+ class MCPController < BaseController
23
23
  # Authenticated by API key rather than by the host app's sessions.
24
24
  allow_unauthenticated_access
25
- before_action :authenticate_api_key!
25
+ before_action :authenticate_api_key!, except: [ :unsupported ]
26
26
 
27
27
  PROTOCOL_VERSION = "2025-03-26"
28
28
  JSONRPC_METHOD_NOT_FOUND = -32601
@@ -51,10 +51,20 @@ module ActionAgent
51
51
  rescue McpError => e
52
52
  render_error(request_id, e.code, e.message)
53
53
  rescue StandardError => e
54
- Rails.logger.error("[Api::McpController] #{e.class}: #{e.message}")
54
+ Rails.logger.error("[Api::MCPController] #{e.class}: #{e.message}")
55
55
  render_error(request_id, JSONRPC_SERVER_ERROR, "Internal error")
56
56
  end
57
57
 
58
+ # GET (open an SSE stream) and DELETE (end a session) on the endpoint.
59
+ # Neither is offered: per Streamable HTTP a server that does not
60
+ # provide a stream MUST answer GET with 405, and an unsupported
61
+ # session DELETE likewise. Unauthenticated on purpose — a client
62
+ # probing for the stream should learn "not offered", not "sign in".
63
+ def unsupported
64
+ response.headers["Allow"] = "POST"
65
+ head :method_not_allowed
66
+ end
67
+
58
68
  private
59
69
 
60
70
  # JSON-RPC errors ride on HTTP 200 per the MCP Streamable HTTP transport.