actionagent 1.2.2 → 1.5.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 +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- 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/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- 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 +3 -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 +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- 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 +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -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 +126 -3
- 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/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -4,8 +4,24 @@ 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
|
|
13
|
+
rescue_from ActiveAgent::Evals::ScenarioParser::ParseError do |error|
|
|
14
|
+
render json: { errors: [ error.message ] }, status: :unprocessable_entity
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
before_action :require_owner!
|
|
18
|
+
# A scenario suite replays its prompts through the provider, so creating
|
|
19
|
+
# one that runs, or running one, executes the agent and is gated the way
|
|
20
|
+
# AgentsController#execute is: the dashboard's execution switch, no
|
|
21
|
+
# observed (read-only) agents, and the owner's execution quota. Each
|
|
22
|
+
# replay then counts as one execution (ScenarioEvaluationRunner#replay).
|
|
23
|
+
before_action :require_execution_enabled!, :require_executable_scenario_agent!, :enforce_execution_quota!,
|
|
24
|
+
only: [ :create, :run ], if: :replays_scenarios?
|
|
9
25
|
|
|
10
26
|
# Default criteria used when none are supplied — all rule-based, so a
|
|
11
27
|
# new evaluation produces real scores without provider credentials.
|
|
@@ -25,7 +41,7 @@ module ActionAgent
|
|
|
25
41
|
def index
|
|
26
42
|
scope = evaluations_scope
|
|
27
43
|
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
28
|
-
evaluations = scope.includes(:agent, :evaluation_runs).recent.limit(50)
|
|
44
|
+
evaluations = scope.includes(:agent, :evaluation_runs, :scenarios).recent.limit(50)
|
|
29
45
|
|
|
30
46
|
render json: { evaluations: evaluations.map { |evaluation| serialize(evaluation) } }
|
|
31
47
|
end
|
|
@@ -36,6 +52,7 @@ module ActionAgent
|
|
|
36
52
|
|
|
37
53
|
render json: {
|
|
38
54
|
evaluation: serialize(evaluation).merge(
|
|
55
|
+
scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
|
|
39
56
|
runs: evaluation.evaluation_runs.recent.limit(20).map { |run| serialize_run(run) }
|
|
40
57
|
)
|
|
41
58
|
}
|
|
@@ -43,11 +60,12 @@ module ActionAgent
|
|
|
43
60
|
|
|
44
61
|
# POST /api/evaluations
|
|
45
62
|
def create
|
|
46
|
-
agent =
|
|
63
|
+
agent = requested_agent
|
|
47
64
|
|
|
48
65
|
judge_kind = evaluation_params[:judge_kind].presence || "rules"
|
|
49
66
|
config = {}
|
|
50
67
|
config["compare_models"] = compare_models_param if compare_models_param.any?
|
|
68
|
+
scenarios = scenario_attributes
|
|
51
69
|
|
|
52
70
|
evaluation = agent.evaluations.new(
|
|
53
71
|
name: evaluation_params[:name],
|
|
@@ -59,9 +77,15 @@ module ActionAgent
|
|
|
59
77
|
criteria: judge_kind == "judge_defined" ? explicit_criteria : normalized_criteria,
|
|
60
78
|
config: config
|
|
61
79
|
)
|
|
80
|
+
scenarios.each_with_index do |attrs, index|
|
|
81
|
+
evaluation.scenarios.build(
|
|
82
|
+
key: attrs["key"], prompt: attrs["prompt"], group: attrs["group"], notes: attrs["notes"],
|
|
83
|
+
expectations: attrs["expectations"] || {}, position: attrs.fetch("position", index)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
62
86
|
|
|
63
87
|
if evaluation.save
|
|
64
|
-
evaluation
|
|
88
|
+
start_run(evaluation, selection_params) if run_requested?
|
|
65
89
|
render json: { evaluation: serialize(evaluation.reload) }, status: :created
|
|
66
90
|
else
|
|
67
91
|
render json: { errors: evaluation.errors.full_messages }, status: :unprocessable_entity
|
|
@@ -69,13 +93,94 @@ module ActionAgent
|
|
|
69
93
|
end
|
|
70
94
|
|
|
71
95
|
# POST /api/evaluations/:id/run
|
|
96
|
+
# A scenario suite accepts a selection: scenario_ids[], keys[], group,
|
|
97
|
+
# models[] (or a comma-separated `models` string).
|
|
72
98
|
def run
|
|
73
|
-
evaluation =
|
|
74
|
-
run = evaluation
|
|
99
|
+
evaluation = current_evaluation
|
|
100
|
+
run = start_run(evaluation, selection_params)
|
|
75
101
|
|
|
76
102
|
render json: { evaluation: serialize(evaluation.reload), run: serialize_run(run) }
|
|
77
103
|
end
|
|
78
104
|
|
|
105
|
+
# GET /api/evaluations/:id/runs/:run_id
|
|
106
|
+
# One run in full: its per-scenario, per-model results alongside the
|
|
107
|
+
# scenarios, so the matrix and every answer can be rendered, and its
|
|
108
|
+
# fix items — the faults grouped with the tools, MCP server and
|
|
109
|
+
# dashboard action that address each. Fix item paths are relative to
|
|
110
|
+
# the mount: the React app resolves them itself (dashboardPath).
|
|
111
|
+
def show_run
|
|
112
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
113
|
+
run = evaluation.evaluation_runs.find(params[:run_id])
|
|
114
|
+
results = run.scenario_results.includes(:scenario).joins(:scenario)
|
|
115
|
+
.order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
|
|
116
|
+
|
|
117
|
+
render json: {
|
|
118
|
+
evaluation: serialize(evaluation),
|
|
119
|
+
run: serialize_run(run).merge(results: results.map(&:as_json_summary), fix_items: safe_fix_items(run))
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# GET /api/evaluations/:id/scenarios
|
|
124
|
+
def scenarios
|
|
125
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
126
|
+
|
|
127
|
+
render json: {
|
|
128
|
+
scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
|
|
129
|
+
groups: evaluation.scenario_groups
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# PUT /api/evaluations/:id/scenarios
|
|
134
|
+
# Replaces the suite from pasted text (`scenarios_text`) or a list
|
|
135
|
+
# (`scenarios`). Scenarios whose key survives keep their results.
|
|
136
|
+
def replace_scenarios
|
|
137
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
138
|
+
attributes = scenario_attributes
|
|
139
|
+
return render json: { errors: [ "No scenarios found in the pasted text" ] }, status: :unprocessable_entity if attributes.empty?
|
|
140
|
+
|
|
141
|
+
evaluation.replace_scenarios!(attributes)
|
|
142
|
+
|
|
143
|
+
render json: {
|
|
144
|
+
evaluation: serialize(evaluation.reload),
|
|
145
|
+
scenarios: evaluation.scenarios.ordered.map(&:as_json_summary),
|
|
146
|
+
groups: evaluation.scenario_groups
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# PATCH /api/evaluations/:id/scenarios/:scenario_id
|
|
151
|
+
def update_scenario
|
|
152
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
153
|
+
scenario = evaluation.scenarios.find(params[:scenario_id])
|
|
154
|
+
scenario.update!(scenario_params)
|
|
155
|
+
|
|
156
|
+
render json: { scenario: scenario.as_json_summary }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# DELETE /api/evaluations/:id/scenarios/:scenario_id
|
|
160
|
+
def destroy_scenario
|
|
161
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
162
|
+
evaluation.scenarios.find(params[:scenario_id]).destroy!
|
|
163
|
+
head :no_content
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# GET /api/evaluations/:id/runs/:run_id/report?theme=dark
|
|
167
|
+
#
|
|
168
|
+
# The run as the framework's self-contained HTML report page — the
|
|
169
|
+
# in-dashboard view and, because the page is a single file, the export.
|
|
170
|
+
# `theme` (light|dark) pins the palette to the dashboard's; without it
|
|
171
|
+
# the page follows the viewer's own preference. The page is served
|
|
172
|
+
# outside the React app, so its fix item actions link at the absolute
|
|
173
|
+
# mount path (request.script_name) rather than relative to it.
|
|
174
|
+
def run_report
|
|
175
|
+
evaluation = evaluations_scope.find(params[:id])
|
|
176
|
+
run = evaluation.evaluation_runs.find(params[:run_id])
|
|
177
|
+
raise ActiveRecord::RecordNotFound unless evaluation.scenario_suite?
|
|
178
|
+
|
|
179
|
+
report = run.to_report(links: run.report_links(mount: request.script_name))
|
|
180
|
+
|
|
181
|
+
render html: report.to_html(theme: params[:theme]).html_safe, layout: false
|
|
182
|
+
end
|
|
183
|
+
|
|
79
184
|
# DELETE /api/evaluations/:id
|
|
80
185
|
def destroy
|
|
81
186
|
evaluations_scope.find(params[:id]).destroy!
|
|
@@ -84,14 +189,122 @@ module ActionAgent
|
|
|
84
189
|
|
|
85
190
|
private
|
|
86
191
|
|
|
192
|
+
# A scenario suite replays through the provider once per scenario and
|
|
193
|
+
# model, so it runs in the background; a generation-sampling evaluation
|
|
194
|
+
# scores recorded data and finishes inline.
|
|
195
|
+
def start_run(evaluation, selection)
|
|
196
|
+
return evaluation.run_later!(**selection) if evaluation.scenario_suite?
|
|
197
|
+
|
|
198
|
+
# EvaluationRunnerService marks the run failed with the error message
|
|
199
|
+
# and then re-raises. Letting that escape returned an HTML 500 for a
|
|
200
|
+
# request that had already persisted the evaluation and its failed
|
|
201
|
+
# run: the client saw a JSON parse error, the form stayed open, and a
|
|
202
|
+
# resubmit failed on the now-taken name. The failure is on the run
|
|
203
|
+
# record, which is what the response carries.
|
|
204
|
+
evaluation.run!
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
Rails.logger.warn(
|
|
207
|
+
"[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
|
|
208
|
+
)
|
|
209
|
+
# The service records the failure before re-raising; a failure that
|
|
210
|
+
# predates the run record (creating it, say) is recorded here so the
|
|
211
|
+
# response always carries one.
|
|
212
|
+
evaluation.evaluation_runs.recent.first ||
|
|
213
|
+
evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
|
|
214
|
+
end
|
|
215
|
+
|
|
87
216
|
def evaluations_scope
|
|
88
217
|
Evaluation.joins(:agent).where(agent: owner_agents)
|
|
89
218
|
end
|
|
90
219
|
|
|
220
|
+
def current_evaluation
|
|
221
|
+
@current_evaluation ||= evaluations_scope.find(params[:id])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def requested_agent
|
|
225
|
+
@requested_agent ||= owner_agents.find(params.require(:evaluation)[:agent_id])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# create runs the new evaluation unless told not to.
|
|
229
|
+
def run_requested?
|
|
230
|
+
run = params.require(:evaluation)[:run]
|
|
231
|
+
run != false && run != "false"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Whether this request replays scenarios through the agent: running a
|
|
235
|
+
# scenario suite, or creating an evaluation with scenarios that runs.
|
|
236
|
+
def replays_scenarios?
|
|
237
|
+
case action_name
|
|
238
|
+
when "run" then current_evaluation.scenario_suite?
|
|
239
|
+
when "create" then run_requested? && scenario_attributes.any?
|
|
240
|
+
else false
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Observed agents cannot use the engine's execution service. A persisted
|
|
245
|
+
# evaluation with an explicit host adapter runs in that source instead.
|
|
246
|
+
def require_executable_scenario_agent!
|
|
247
|
+
agent = action_name == "create" ? requested_agent : current_evaluation.agent
|
|
248
|
+
return unless agent.observed?
|
|
249
|
+
if action_name == "run"
|
|
250
|
+
adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(current_evaluation)
|
|
251
|
+
return if adapter.respond_to?(:call)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
render json: {
|
|
255
|
+
error: "Observed agents are read-only — duplicate this agent to create an executable copy"
|
|
256
|
+
}, status: :unprocessable_entity
|
|
257
|
+
end
|
|
258
|
+
|
|
91
259
|
def evaluation_params
|
|
92
260
|
params.require(:evaluation).permit(:agent_id, :name, :judge_kind, :judge_model, :sample_size)
|
|
93
261
|
end
|
|
94
262
|
|
|
263
|
+
def scenario_params
|
|
264
|
+
params.require(:scenario).permit(:prompt, :group, :notes, :enabled, :key, expectations: {})
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# scenario_ids, keys, group and models narrow a scenario run. `models`
|
|
268
|
+
# may arrive as an array or as the comma-separated field the form posts.
|
|
269
|
+
def selection_params
|
|
270
|
+
source = params[:evaluation].is_a?(ActionController::Parameters) && params[:evaluation].key?(:selection) ? params[:evaluation][:selection] : params
|
|
271
|
+
models = source[:models]
|
|
272
|
+
models = models.to_s.split(",") unless models.is_a?(Array)
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
scenario_ids: Array(source[:scenario_ids]).map(&:to_s).reject(&:blank?),
|
|
276
|
+
keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
|
|
277
|
+
group: source[:group].to_s.presence,
|
|
278
|
+
models: models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
279
|
+
}.compact_blank
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Scenarios from text, a YAML/JSON suite, or a list of objects. Production
|
|
283
|
+
# questions are selected at import time, only on explicit opt-in; the
|
|
284
|
+
# persisted scenario records do not store an environment flag.
|
|
285
|
+
def scenario_attributes
|
|
286
|
+
@scenario_attributes ||= begin
|
|
287
|
+
source = params[:evaluation].presence || params
|
|
288
|
+
text = source[:scenarios_text].to_s
|
|
289
|
+
list = source[:scenarios]
|
|
290
|
+
include_production_only = ActiveModel::Type::Boolean.new.cast(source[:include_production_only]) == true
|
|
291
|
+
|
|
292
|
+
imported = if list.present?
|
|
293
|
+
list = list.to_unsafe_h.values if list.is_a?(ActionController::Parameters)
|
|
294
|
+
serialized = Array(list).map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }.to_json
|
|
295
|
+
ActiveAgent::Evals::ScenarioParser.parse(serialized, include_production_only: include_production_only)
|
|
296
|
+
elsif text.present?
|
|
297
|
+
ActiveAgent::Evals::ScenarioParser.parse(text, include_production_only: include_production_only)
|
|
298
|
+
else
|
|
299
|
+
[]
|
|
300
|
+
end
|
|
301
|
+
if (list.present? || text.present?) && imported.empty?
|
|
302
|
+
raise ActiveAgent::Evals::ScenarioParser::ParseError, "No scenarios matched the import. Check the catalog or include_production_only selection."
|
|
303
|
+
end
|
|
304
|
+
imported
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
95
308
|
def normalized_criteria
|
|
96
309
|
explicit_criteria.presence || DEFAULT_CRITERIA.deep_dup
|
|
97
310
|
end
|
|
@@ -109,7 +322,9 @@ module ActionAgent
|
|
|
109
322
|
end
|
|
110
323
|
|
|
111
324
|
def compare_models_param
|
|
112
|
-
|
|
325
|
+
models = params[:evaluation][:compare_models]
|
|
326
|
+
models = models.to_s.split(",") unless models.is_a?(Array)
|
|
327
|
+
models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
113
328
|
end
|
|
114
329
|
|
|
115
330
|
def serialize(evaluation)
|
|
@@ -125,6 +340,9 @@ module ActionAgent
|
|
|
125
340
|
compare_models: evaluation.compare_models,
|
|
126
341
|
config: evaluation.config,
|
|
127
342
|
sample_size: evaluation.sample_size,
|
|
343
|
+
scenario_suite: evaluation.scenario_suite?,
|
|
344
|
+
scenario_count: evaluation.scenarios.size,
|
|
345
|
+
scenario_groups: evaluation.scenario_suite? ? evaluation.scenario_groups : [],
|
|
128
346
|
created_at: evaluation.created_at.iso8601,
|
|
129
347
|
latest_run: latest ? serialize_run(latest) : nil
|
|
130
348
|
}
|
|
@@ -135,14 +353,41 @@ module ActionAgent
|
|
|
135
353
|
id: run.id,
|
|
136
354
|
status: run.status,
|
|
137
355
|
scores: run.scores,
|
|
138
|
-
|
|
356
|
+
selection: run.selection,
|
|
357
|
+
models: run.models,
|
|
358
|
+
average_score: safe_average_score(run),
|
|
139
359
|
samples_evaluated: run.samples_evaluated,
|
|
140
360
|
samples_passed: run.samples_passed,
|
|
361
|
+
usage: run.usage,
|
|
141
362
|
error_message: run.error_message,
|
|
142
363
|
completed_at: run.completed_at&.iso8601,
|
|
143
364
|
created_at: run.created_at.iso8601
|
|
144
365
|
}
|
|
145
366
|
end
|
|
367
|
+
|
|
368
|
+
# The fix items are derived from every persisted result's diagnosis,
|
|
369
|
+
# which older runs recorded in earlier shapes; a run they cannot be
|
|
370
|
+
# built for still serves its results rather than 500-ing the panel.
|
|
371
|
+
def safe_fix_items(run)
|
|
372
|
+
run.fix_items
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
Rails.logger.warn(
|
|
375
|
+
"[ActionAgent] evaluation run #{run.id} fix_items failed: #{e.class}: #{e.message}"
|
|
376
|
+
)
|
|
377
|
+
[]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# index serializes the latest run of every listed evaluation, so an
|
|
381
|
+
# unaverageable scores payload used to 500 the entire Evaluations page
|
|
382
|
+
# instead of degrading that one run's headline number.
|
|
383
|
+
def safe_average_score(run)
|
|
384
|
+
run.average_score
|
|
385
|
+
rescue StandardError => e
|
|
386
|
+
Rails.logger.warn(
|
|
387
|
+
"[ActionAgent] evaluation run #{run.id} average_score failed: #{e.class}: #{e.message}"
|
|
388
|
+
)
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
146
391
|
end
|
|
147
392
|
end
|
|
148
393
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Edits to a conversation context from the agent runner: seed a user or
|
|
6
|
+
# assistant turn without running the agent, fix a turn's text, or drop
|
|
7
|
+
# one — so the next run sees exactly the history the tester intends.
|
|
8
|
+
#
|
|
9
|
+
# Only user and assistant turns are editable. Tool and system rows are
|
|
10
|
+
# written by execution and read back as call/result pairs; half a pair
|
|
11
|
+
# is worse than an uneditable one.
|
|
12
|
+
class InteractionMessagesController < BaseController
|
|
13
|
+
EDITABLE_ROLES = %w[user assistant].freeze
|
|
14
|
+
|
|
15
|
+
# Marks turns typed into the context by hand, as opposed to the
|
|
16
|
+
# provenance SolidAgent stamps on turns a run produced.
|
|
17
|
+
MANUAL_PROVENANCE = { "source" => "dashboard", "manual" => true }.freeze
|
|
18
|
+
|
|
19
|
+
rescue_from Agent::ObservedAgentError, with: :observed_agent_read_only
|
|
20
|
+
|
|
21
|
+
before_action :require_owner!
|
|
22
|
+
before_action :set_context
|
|
23
|
+
before_action :require_executable_agent!, only: [ :create, :update, :destroy ]
|
|
24
|
+
before_action :set_message, only: [ :update, :destroy ]
|
|
25
|
+
|
|
26
|
+
# POST /api/interactions/:interaction_id/messages
|
|
27
|
+
def create
|
|
28
|
+
role = params[:role].to_s
|
|
29
|
+
unless EDITABLE_ROLES.include?(role)
|
|
30
|
+
return render json: { error: "role must be user or assistant" }, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
content = params[:content].to_s
|
|
34
|
+
return render json: { error: "content can't be blank" }, status: :unprocessable_entity if content.blank?
|
|
35
|
+
|
|
36
|
+
message = @context.messages.create!(
|
|
37
|
+
role: role,
|
|
38
|
+
content: content,
|
|
39
|
+
content_checksum: Digest::MD5.hexdigest(content),
|
|
40
|
+
provenance: MANUAL_PROVENANCE
|
|
41
|
+
)
|
|
42
|
+
@context.touch
|
|
43
|
+
|
|
44
|
+
render json: { message: AgentMessageSerializer.call(message) }, status: :created
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# PATCH /api/interactions/:interaction_id/messages/:id
|
|
48
|
+
def update
|
|
49
|
+
content = params[:content].to_s
|
|
50
|
+
return render json: { error: "content can't be blank" }, status: :unprocessable_entity if content.blank?
|
|
51
|
+
|
|
52
|
+
@message.update!(content: content, content_checksum: Digest::MD5.hexdigest(content))
|
|
53
|
+
@context.touch
|
|
54
|
+
|
|
55
|
+
render json: { message: AgentMessageSerializer.call(@message) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# DELETE /api/interactions/:interaction_id/messages/:id
|
|
59
|
+
def destroy
|
|
60
|
+
@message.destroy!
|
|
61
|
+
@context.touch
|
|
62
|
+
|
|
63
|
+
head :no_content
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def set_context
|
|
69
|
+
@context = AgentContext.for_agents(owner_agents).find(params[:interaction_id])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Seeding, fixing or dropping a turn writes the agent's own history, so
|
|
73
|
+
# it answers to the same read-only policy execution does (#414): an
|
|
74
|
+
# observed agent is a mirror of someone else's telemetry, and a turn
|
|
75
|
+
# typed in here would be a fabrication attributed to it. Asked of the
|
|
76
|
+
# agent rather than re-tested here, so `observed` has one definition and
|
|
77
|
+
# one message. Reads are left open — every action this controller has is
|
|
78
|
+
# a write; the conversation itself is still listed and shown.
|
|
79
|
+
def require_executable_agent!
|
|
80
|
+
agent = @context.contextable
|
|
81
|
+
agent.ensure_executable! if agent.respond_to?(:ensure_executable!)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def observed_agent_read_only(exception)
|
|
85
|
+
render json: { error: exception.message }, status: :unprocessable_entity
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Looked up through the context, so a message id from another
|
|
89
|
+
# conversation is a 404 rather than a cross-conversation edit.
|
|
90
|
+
def set_message
|
|
91
|
+
@message = @context.messages.find(params[:id])
|
|
92
|
+
return if EDITABLE_ROLES.include?(@message.role)
|
|
93
|
+
|
|
94
|
+
render json: { error: "Only user and assistant messages can be edited" }, status: :unprocessable_entity
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
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.
|
|
@@ -7,16 +7,16 @@ module ActionAgent
|
|
|
7
7
|
#
|
|
8
8
|
# The list is the union of three things: servers detected from telemetry
|
|
9
9
|
# and solid_agent records (ToolDiscovery), servers an agent declares in
|
|
10
|
-
# its configuration, and the default catalog (
|
|
10
|
+
# its configuration, and the default catalog (MCPCatalog). An install
|
|
11
11
|
# therefore sees both what it is already using and what it could turn on.
|
|
12
|
-
class
|
|
12
|
+
class MCPServersController < BaseController
|
|
13
13
|
before_action :require_owner!
|
|
14
14
|
# Launching provisions a sandbox and runs a server in it, so it answers
|
|
15
15
|
# to the same two gates as any other execution: the read-only kill
|
|
16
16
|
# switch, and whatever limits the host app imposes.
|
|
17
17
|
before_action :require_execution_enabled!, only: [ :launch ]
|
|
18
18
|
before_action :enforce_execution_quota!, only: [ :launch ]
|
|
19
|
-
before_action :set_catalog_entry, only: [ :
|
|
19
|
+
before_action :set_catalog_entry, only: [ :launch ]
|
|
20
20
|
|
|
21
21
|
STATUS_LABELS = {
|
|
22
22
|
"active" => "Called in this window",
|
|
@@ -32,8 +32,8 @@ module ActionAgent
|
|
|
32
32
|
servers = finder.servers(tools)
|
|
33
33
|
|
|
34
34
|
render json: {
|
|
35
|
-
servers: servers,
|
|
36
|
-
catalog:
|
|
35
|
+
servers: servers.map { |server| at_mount(server) },
|
|
36
|
+
catalog: MCPCatalog.all.map { |entry| at_mount(entry) },
|
|
37
37
|
summary: summary_for(servers),
|
|
38
38
|
sandboxes: active_sandboxes,
|
|
39
39
|
window_hours: finder.window_hours,
|
|
@@ -45,13 +45,23 @@ module ActionAgent
|
|
|
45
45
|
#
|
|
46
46
|
# One server with the tools detected for it, so the view can expand a
|
|
47
47
|
# row without refetching the whole inventory.
|
|
48
|
+
#
|
|
49
|
+
# The index is a union of detected, declared and catalog servers, and
|
|
50
|
+
# a server it lists must be fetchable individually — including the
|
|
51
|
+
# ones the catalog doesn't describe (listed as known: false). Discovery
|
|
52
|
+
# is consulted first; the catalog is the fallback, and only when
|
|
53
|
+
# neither knows the key is it a 404.
|
|
48
54
|
def show
|
|
49
55
|
finder = discovery
|
|
50
56
|
tools = finder.detected_tools
|
|
51
|
-
server = finder.servers(tools).find { |row| row[:key] == params[:id] }
|
|
57
|
+
server = finder.servers(tools).find { |row| row[:key] == params[:id] } || MCPCatalog.find(params[:id])
|
|
58
|
+
|
|
59
|
+
if server.nil?
|
|
60
|
+
return render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found
|
|
61
|
+
end
|
|
52
62
|
|
|
53
63
|
render json: {
|
|
54
|
-
server: server
|
|
64
|
+
server: at_mount(server),
|
|
55
65
|
tools: tools.select { |tool| tool[:mcp_server] == params[:id] }
|
|
56
66
|
}
|
|
57
67
|
end
|
|
@@ -102,6 +112,16 @@ module ActionAgent
|
|
|
102
112
|
ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
|
|
103
113
|
end
|
|
104
114
|
|
|
115
|
+
# The catalog names this dashboard's own MCP endpoint as "<mount>/mcp",
|
|
116
|
+
# relative to wherever the engine is mounted; only a request knows
|
|
117
|
+
# where that is. Substituted here, at the edge, so the view shows the
|
|
118
|
+
# endpoint a client can actually connect to rather than the template.
|
|
119
|
+
def at_mount(row)
|
|
120
|
+
return row unless row.is_a?(Hash) && row[:url].is_a?(String) && row[:url].include?("<mount>")
|
|
121
|
+
|
|
122
|
+
row.merge(url: row[:url].sub("<mount>", request.script_name.to_s))
|
|
123
|
+
end
|
|
124
|
+
|
|
105
125
|
def assign_owner(sandbox, association, record)
|
|
106
126
|
return if record.nil?
|
|
107
127
|
return unless sandbox.respond_to?(:"#{association}=")
|
|
@@ -111,7 +131,7 @@ module ActionAgent
|
|
|
111
131
|
end
|
|
112
132
|
|
|
113
133
|
def set_catalog_entry
|
|
114
|
-
@catalog_entry =
|
|
134
|
+
@catalog_entry = MCPCatalog.find(params[:id])
|
|
115
135
|
render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found if @catalog_entry.nil?
|
|
116
136
|
end
|
|
117
137
|
|
|
@@ -4,17 +4,17 @@ module ActionAgent
|
|
|
4
4
|
module Api
|
|
5
5
|
# Read API for telemetry metrics, backing the dashboard Metrics view.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Two generations of keys share the response. The legacy keys expose
|
|
8
|
+
# the same aggregates as the gem dashboard's metrics page
|
|
8
9
|
# (ActionAgent::TracesController#metrics / #calculate_metrics /
|
|
9
|
-
# #agent_statistics): trace counts, token totals, average duration,
|
|
10
|
-
# rate, active agents and per-agent statistics — account-scoped,
|
|
11
|
-
# previous-period deltas for trend indicators.
|
|
10
|
+
# #agent_statistics): trace counts, token totals, average duration,
|
|
11
|
+
# error rate, active agents and per-agent statistics — account-scoped,
|
|
12
|
+
# plus previous-period deltas for trend indicators. The APM keys
|
|
13
|
+
# (series, totals, deltas, rails, markers) come from MetricsReport and
|
|
14
|
+
# drive the service-overview layout; see that class for their shape.
|
|
12
15
|
class MetricsController < BaseController
|
|
13
16
|
before_action :require_owner!
|
|
14
17
|
|
|
15
|
-
DEFAULT_WINDOW_HOURS = 24
|
|
16
|
-
MAX_WINDOW_HOURS = 24 * 30
|
|
17
|
-
|
|
18
18
|
# How to rank the per-agent table. Cost is applied after the grouped
|
|
19
19
|
# query because pricing happens in Ruby (rates vary per model), so all
|
|
20
20
|
# four are ordered in one place rather than half in SQL.
|
|
@@ -28,12 +28,34 @@ module ActionAgent
|
|
|
28
28
|
DEFAULT_AGENT_SORT = "popular"
|
|
29
29
|
|
|
30
30
|
# GET /api/metrics
|
|
31
|
+
#
|
|
32
|
+
# Params:
|
|
33
|
+
# range "1h" | "24h" (default) | "7d" — the APM window and its
|
|
34
|
+
# bucket size (MetricsReport::RANGES). A named range also
|
|
35
|
+
# sets the legacy window (1, 24 or 168 hours) so both halves
|
|
36
|
+
# of the response describe the same period.
|
|
37
|
+
# hours the legacy window; without `range` it makes a "custom"
|
|
38
|
+
# range bucketed to about 96 points. Ignored when `range` is
|
|
39
|
+
# a known value.
|
|
40
|
+
# agent an agent_class. When given, EVERY key is scoped to that
|
|
41
|
+
# agent — the legacy summary, hourly buckets and per-agent
|
|
42
|
+
# table included — so the page never shows a filtered chart
|
|
43
|
+
# next to an unfiltered tile.
|
|
44
|
+
# sort ranks the legacy per-agent table (AGENT_SORTS).
|
|
31
45
|
def show
|
|
32
|
-
hours = params.fetch(:hours, DEFAULT_WINDOW_HOURS).to_i.clamp(1, MAX_WINDOW_HOURS)
|
|
33
46
|
now = Time.current
|
|
47
|
+
report = MetricsReport.new(
|
|
48
|
+
traces: traces_scope,
|
|
49
|
+
agents: owner_agents,
|
|
50
|
+
range: params[:range],
|
|
51
|
+
hours: params[:hours],
|
|
52
|
+
agent: agent_filter,
|
|
53
|
+
now: now
|
|
54
|
+
)
|
|
55
|
+
hours = report.window_hours
|
|
34
56
|
|
|
35
|
-
current =
|
|
36
|
-
previous =
|
|
57
|
+
current = legacy_scope.for_date_range(hours.hours.ago(now), now)
|
|
58
|
+
previous = legacy_scope.for_date_range((hours * 2).hours.ago(now), hours.hours.ago(now))
|
|
37
59
|
|
|
38
60
|
costs = cost_statistics(current)
|
|
39
61
|
priced = agent_statistics(current).map { |row| row.merge(cost: costs[:by_agent][row[:name]] || 0.0) }
|
|
@@ -45,7 +67,7 @@ module ActionAgent
|
|
|
45
67
|
window_hours: hours,
|
|
46
68
|
sorts: AGENT_SORTS,
|
|
47
69
|
sort: agent_sort(params[:sort])
|
|
48
|
-
}
|
|
70
|
+
}.merge(report.to_h)
|
|
49
71
|
end
|
|
50
72
|
|
|
51
73
|
private
|
|
@@ -68,10 +90,21 @@ module ActionAgent
|
|
|
68
90
|
rows.sort_by { |row| [ -row[key].to_f, -row[:requests].to_i ] }
|
|
69
91
|
end
|
|
70
92
|
|
|
93
|
+
def agent_filter
|
|
94
|
+
params[:agent].presence
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every trace the caller can see — what MetricsReport starts from
|
|
98
|
+
# (it applies the agent filter itself).
|
|
71
99
|
def traces_scope
|
|
72
100
|
ActionAgent.trace_model.for_account(current_account)
|
|
73
101
|
end
|
|
74
102
|
|
|
103
|
+
# The legacy keys' scope: traces_scope narrowed to the filtered agent.
|
|
104
|
+
def legacy_scope
|
|
105
|
+
agent_filter ? traces_scope.where(agent_class: agent_filter) : traces_scope
|
|
106
|
+
end
|
|
107
|
+
|
|
75
108
|
# Same definitions as the gem dashboard's calculate_metrics, with
|
|
76
109
|
# previous-period percentage changes layered on top.
|
|
77
110
|
def summary_for(current, previous)
|
|
@@ -74,7 +74,7 @@ module ActionAgent
|
|
|
74
74
|
# Queries the Anthropic Models API with the account's key (newest first,
|
|
75
75
|
# as returned by the API) so new model releases appear without a deploy.
|
|
76
76
|
def live_anthropic_models
|
|
77
|
-
key =
|
|
77
|
+
key = owner_provider_key("anthropic")&.credential
|
|
78
78
|
return nil if key.blank?
|
|
79
79
|
|
|
80
80
|
data = Rails.cache.fetch("provider_models:anthropic:#{Digest::SHA256.hexdigest(key)}", expires_in: 1.hour) do
|