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.
- checksums.yaml +4 -4
- data/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +48 -44
- data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
- data/app/controllers/action_agent/api/agents_controller.rb +76 -48
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +14 -5
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +182 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +110 -2
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/services/action_agent/agent_toolbox.rb +4 -4
- data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/playwright_mcp_client.rb +6 -6
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +25 -2
- data/lib/action_agent/engine.rb +101 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +72 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- 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
|
-
|
|
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
|
-
#
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
#
|
|
217
|
-
|
|
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
|
-
.
|
|
220
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
74
|
-
run = evaluation
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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::
|
|
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.
|