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.
Files changed (67) 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 +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  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/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. 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 = owner_agents.find(params.require(:evaluation)[:agent_id])
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.run!
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 = evaluations_scope.find(params[:id])
74
- run = evaluation.run!
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
- Array(params[:evaluation][:compare_models]).map(&:to_s).reject(&:blank?)
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
- average_score: run.average_score,
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 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.
@@ -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 (McpCatalog). An install
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 McpServersController < BaseController
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: [ :show, :launch ]
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: McpCatalog.all,
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 || @catalog_entry,
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 = McpCatalog.find(params[:id])
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
- # Exposes the same aggregates as the gem dashboard's metrics page
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, error
10
- # rate, active agents and per-agent statistics — account-scoped, plus
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 = traces_scope.for_date_range(hours.hours.ago(now), now)
36
- previous = traces_scope.for_date_range((hours * 2).hours.ago(now), hours.hours.ago(now))
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 = current_user_provider_key("anthropic")&.credential
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