actionagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +75 -62
  4. data/app/controllers/action_agent/api/agents_controller.rb +15 -6
  5. data/app/controllers/action_agent/api/base_controller.rb +3 -2
  6. data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
  7. data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
  8. data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
  9. data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
  10. data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
  11. data/app/controllers/action_agent/traces_controller.rb +3 -2
  12. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
  13. data/app/jobs/action_agent/agent_execution_job.rb +26 -6
  14. data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
  15. data/app/models/action_agent/agent_run.rb +25 -6
  16. data/app/models/action_agent/catalog_scenario.rb +8 -2
  17. data/app/models/action_agent/evaluation_run.rb +54 -3
  18. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  19. data/app/models/action_agent/mcp_server_config.rb +273 -0
  20. data/app/models/action_agent/model_pricing.rb +135 -24
  21. data/app/models/action_agent/scenario_product.rb +24 -0
  22. data/app/models/action_agent/scenario_set.rb +9 -0
  23. data/app/models/action_agent/telemetry_trace.rb +35 -14
  24. data/app/queries/action_agent/agent_executions.rb +4 -1
  25. data/app/queries/action_agent/metrics_report.rb +1 -1
  26. data/app/serializers/action_agent/evaluation_serializer.rb +6 -2
  27. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  28. data/app/services/action_agent/agent_execution_service.rb +4 -2
  29. data/app/services/action_agent/agent_scorecard.rb +16 -12
  30. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  31. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  32. data/app/services/action_agent/evaluation_report_import.rb +63 -4
  33. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  34. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  35. data/app/services/action_agent/mcp_client.rb +36 -4
  36. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  37. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  38. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  39. data/app/services/action_agent/scenario_catalog_import.rb +7 -0
  40. data/app/services/action_agent/scenario_evaluation_runner.rb +135 -18
  41. data/app/services/action_agent/tool_discovery.rb +5 -1
  42. data/config/routes.rb +6 -0
  43. data/lib/action_agent/engine.rb +10 -0
  44. data/lib/action_agent/version.rb +1 -1
  45. data/lib/action_agent.rb +17 -0
  46. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  47. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  48. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  49. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  50. data/lib/generators/action_agent/templates/migrations/034_add_catalog_product_criteria.rb.erb +24 -0
  51. data/lib/generators/action_agent/templates/migrations/035_add_catalog_pass_bars.rb.erb +28 -0
  52. metadata +10 -2
@@ -23,6 +23,12 @@ module ActionAgent
23
23
  # is reached the same way (SandboxSession.browser_server_entry), and only
24
24
  # this way. Only those keys are taken; a catalog server the agent does not
25
25
  # enable stays out of reach.
26
+ #
27
+ # A server a dashboard user added on the MCP Services page is enabled as
28
+ # "custom:<id>" and resolves through MCPServerConfig.server_entry, among the
29
+ # agent's owner's configurations only and only while it is enabled. Its
30
+ # client refuses a host that resolves to a non-public address (see
31
+ # MCPHostGuard) unless the host app allows them.
26
32
  class MCPToolDispatcher
27
33
  HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
28
34
  class SandboxUnavailable < MCPClient::Error; end
@@ -43,6 +49,7 @@ module ActionAgent
43
49
  def self.unreachable_reason(entry)
44
50
  return "Unknown to the catalog" if entry.nil?
45
51
  return nil if SandboxSession.runtime_server_key?(entry[:key].to_s)
52
+ return "Switched off on the MCP Services page" if entry[:custom] && entry[:enabled] == false
46
53
 
47
54
  transport = entry[:transport].to_s
48
55
  unless transport.in?(HTTP_TRANSPORTS)
@@ -284,14 +291,16 @@ module ActionAgent
284
291
  end
285
292
 
286
293
  # A catalog server, the app runtime of a checkout sandbox the agent's
287
- # owner started (keys "sandbox:<session_id>"), or, for a key this run was
288
- # given, that sandbox's browser ("browser:<session_id>"). The sandbox
289
- # lookups are scoped to the agent's owner, so naming another tenant's
290
- # session resolves to nothing.
294
+ # owner started (keys "sandbox:<session_id>"), a server the agent's owner
295
+ # added ("custom:<id>"), or, for a key this run was given, that sandbox's
296
+ # browser ("browser:<session_id>"). The sandbox and custom lookups are
297
+ # scoped to the agent's owner, so naming another tenant's session or
298
+ # configuration resolves to nothing.
291
299
  def catalog_entry(key)
292
300
  if SandboxSession.browser_server_key?(key)
293
301
  return extra_server_keys.include?(key) ? SandboxSession.browser_server_entry(key, owner: agent.try(:owner)) : nil
294
302
  end
303
+ return MCPServerConfig.server_entry(key, owner: agent.try(:owner)) if MCPServerConfig.key?(key)
295
304
  return MCPCatalog.connection(key) unless SandboxSession.runtime_server_key?(key)
296
305
 
297
306
  SandboxSession.runtime_server_entry(key, owner: agent.try(:owner))
@@ -303,7 +312,8 @@ module ActionAgent
303
312
  @clients[entry[:key]] ||= MCPClient.new(
304
313
  url: absolute_url(entry[:url]),
305
314
  label: entry[:name] || entry[:key],
306
- headers: entry[:headers] || {}
315
+ headers: entry[:headers] || {},
316
+ public_only: entry[:public_only] ? true : false
307
317
  )
308
318
  end
309
319
 
@@ -10,12 +10,19 @@ module ActionAgent
10
10
  # The host is the OpenAI-compatible base URL (".../v1"); the probe calls
11
11
  # GET {host}/models. An optional API key is sent as a Bearer token for
12
12
  # remote servers behind an authenticating proxy or Ollama Cloud.
13
+ #
14
+ # The result says whether the host is Ollama Cloud (`cloud`) and, for
15
+ # each listed model a price list names, its rate (`rates`): the cloud's
16
+ # models are metered per token, and a local server's copy of a cloud
17
+ # model is priced at the same list rate (ModelPricing). A model priced
18
+ # only by a guess has no entry.
13
19
  class OllamaHostProbe
14
20
  TIMEOUT_SECONDS = 4
21
+ CLOUD_HOST = "ollama.com"
15
22
 
16
- Result = Struct.new(:ok, :host, :models, :latency_ms, :error, keyword_init: true) do
23
+ Result = Struct.new(:ok, :host, :models, :latency_ms, :error, :cloud, :rates, keyword_init: true) do
17
24
  def to_h
18
- { ok: ok, host: host, models: models, latency_ms: latency_ms, error: error }
25
+ { ok: ok, host: host, models: models, latency_ms: latency_ms, error: error, cloud: cloud, rates: rates }
19
26
  end
20
27
  end
21
28
 
@@ -23,11 +30,24 @@ module ActionAgent
23
30
  new(host: host, api_key: api_key).call
24
31
  end
25
32
 
33
+ # Whether +host+ addresses Ollama Cloud (the framework's
34
+ # ActiveAgent::Providers::Ollama::Cloud.cloud_host?, kept in step).
35
+ def self.cloud_host?(host)
36
+ name = URI.parse(host.to_s.strip).host.to_s.downcase
37
+ name == CLOUD_HOST || name.end_with?(".#{CLOUD_HOST}")
38
+ rescue URI::InvalidURIError
39
+ false
40
+ end
41
+
26
42
  def initialize(host:, api_key: nil)
27
43
  @host = ProviderKey.normalize_host(host)
28
44
  @api_key = api_key.presence
29
45
  end
30
46
 
47
+ def cloud?
48
+ self.class.cloud_host?(@host)
49
+ end
50
+
31
51
  def call
32
52
  return failure("No host configured") if @host.blank?
33
53
 
@@ -49,7 +69,7 @@ module ActionAgent
49
69
 
50
70
  data = JSON.parse(response.body)
51
71
  models = Array(data["data"]).filter_map { |model| model["id"] }.sort
52
- Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil)
72
+ Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil, cloud: cloud?, rates: rates_for(models))
53
73
  rescue JSON::ParserError
54
74
  failure("#{@host} did not return JSON — is this the OpenAI-compatible /v1 endpoint?")
55
75
  rescue URI::InvalidURIError, ArgumentError
@@ -68,8 +88,20 @@ module ActionAgent
68
88
  @api_key ? { "Authorization" => "Bearer #{@api_key}" } : {}
69
89
  end
70
90
 
91
+ # `{ "gpt-oss:120b" => { input: 0.15, output: 0.6, source: "catalog" } }`
92
+ # for every listed model a price list names, in $ per million tokens.
93
+ def rates_for(models)
94
+ provider = cloud? ? ModelPricing::OLLAMA_CLOUD_PROVIDER : "ollama"
95
+ models.each_with_object({}) do |id, rates|
96
+ detail = ModelPricing.rate_detail(id, provider: provider)
97
+ next if ModelPricing::FALLBACK_SOURCES.include?(detail[:source])
98
+
99
+ rates[id] = detail.slice(:input, :output, :source)
100
+ end
101
+ end
102
+
71
103
  def failure(message, latency_ms = nil)
72
- Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message)
104
+ Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message, cloud: cloud?, rates: {})
73
105
  end
74
106
  end
75
107
  end
@@ -181,6 +181,10 @@ module ActionAgent
181
181
  metadata: entry.metadata, agent: resolve_agent(entry) || (merge? ? product.agent : nil),
182
182
  project: resolve_project(entry) || (merge? ? product.project : nil)
183
183
  )
184
+ # Until add_catalog_product_criteria runs, imports keep working without the lists.
185
+ product.criteria = entry.criteria if product.has_attribute?(:criteria)
186
+ # Until add_catalog_pass_bars runs, imports keep working without the bars.
187
+ product.pass = entry.pass if product.has_attribute?(:pass)
184
188
  product.position = position_for(product, entry.position, offset)
185
189
  product.save!
186
190
  replace_sets(product, entry)
@@ -197,6 +201,7 @@ module ActionAgent
197
201
  name: set_entry.name, description: set_entry.description, judge: set_entry.judge,
198
202
  criteria: set_entry.criteria, metadata: set_entry.metadata
199
203
  )
204
+ set.pass = set_entry.pass if set.has_attribute?(:pass)
200
205
  set.position = position_for(set, set_entry.position, offset)
201
206
  set.save!
202
207
  replace_scenarios(set, set_entry)
@@ -213,6 +218,8 @@ module ActionAgent
213
218
  prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
214
219
  tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
215
220
  )
221
+ # Until add_catalog_scenario_metadata runs, imports keep working without extensions.
222
+ record.metadata = scenario.metadata if record.has_attribute?(:metadata)
216
223
  record.position = position_for(record, scenario.position, offset)
217
224
  record.save!
218
225
  end
@@ -22,6 +22,11 @@ module ActionAgent
22
22
  # decisive (ActiveAgent::Evals::Comparison)
23
23
  # "_selection" — the scenarios and models this run covered, and
24
24
  # the checkout sandbox it replayed against, if any
25
+ # "_pass_thresholds" — for an evaluation of a catalog set that declares
26
+ # `pass` bars: the bars, recorded as the run starts,
27
+ # so every replay and the completion gate by the same
28
+ # ones however the catalog changes meanwhile
29
+ # "_gates" — with them: each group's gate per model, met or not
25
30
  #
26
31
  # A selection's `sandbox_id` names a checkout sandbox whose app runtime
27
32
  # every replay reaches beside the agent's own MCP servers, as if the agent
@@ -41,6 +46,7 @@ module ActionAgent
41
46
  # any replay, and one started here is stopped once the run ends.
42
47
  class ScenarioEvaluationRunner < EvaluationRunnerService
43
48
  Evals = ActiveAgent::Evals
49
+ JUDGE_LEASE_DURATION = 30.minutes
44
50
 
45
51
  # The providers a candidate model name resolves against. `mock` is the
46
52
  # framework's test double, accepted so the test suite can compare cohorts
@@ -80,6 +86,7 @@ module ActionAgent
80
86
  specs = model_specs
81
87
  run = @run || @evaluation.evaluation_runs.create!(status: :pending)
82
88
  run.update!(status: :running, selection: selection_summary(scenarios, specs))
89
+ record_pass_thresholds!(run)
83
90
  ensure_sandbox_live!
84
91
 
85
92
  if scenarios.empty?
@@ -124,7 +131,7 @@ module ActionAgent
124
131
  adapter.call(evaluation: @evaluation, owner: owner, scenarios: tasks, models: specs, on_result: metered)
125
132
  else
126
133
  ensure_judge_defined_kpis! if @evaluation.judge_defined?
127
- default_report(tasks, specs, on_result)
134
+ default_report(tasks, specs, on_result, run)
128
135
  end
129
136
  raise ArgumentError, "scenario evaluation adapter must return an ActiveAgent::Evals::Report" unless report.is_a?(Evals::Report)
130
137
  reported = report.results.map { |result| [ result.scenario.key, result.label ] }
@@ -187,17 +194,20 @@ module ActionAgent
187
194
  # A job delivered again for a result that is already scored, or that a
188
195
  # concurrent delivery has claimed, scores nothing. It still checks
189
196
  # whether the run is complete: the earlier delivery may have stored the
190
- # last result and stopped before completing the run.
197
+ # last result and stopped before completing the run. A result another
198
+ # delivery holds a live lease on is tried again when the lease expires
199
+ # (see #wait_for_claim!).
191
200
  def judge_result!(row)
192
201
  run = row.evaluation_run
193
202
  return unless run.running?
194
- return finish_fan_out!(run) unless row.pending? && claim_result!(row)
203
+ return finish_fan_out!(run) unless row.pending?
204
+ return wait_for_claim!(row, run) unless claim_result!(row)
195
205
 
196
206
  spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do
197
207
  Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model)
198
208
  end
199
209
  result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row))
200
- row.update!(
210
+ store_claimed_result!(row,
201
211
  status: result.status,
202
212
  score: result.score,
203
213
  scores: result.scores,
@@ -227,7 +237,15 @@ module ActionAgent
227
237
  row = stored_result(run, scenario, spec)
228
238
  task = Evals::Scenario.from_hash(scenario.as_json_summary)
229
239
  row ||= store_replay(run, scenario, task, spec, Evals::Replay.failed(error))
230
- row.update!(status: :errored, error_message: row.error_message.presence || error_text(error)) if row.pending?
240
+ attributes = { status: :errored, error_message: row.error_message.presence || error_text(error) }
241
+ if scoring_leases?
242
+ # Another replay delivery may fail while this result is being
243
+ # scored. Only an unclaimed replay belongs to this failure path.
244
+ EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: nil)
245
+ .update_all(attributes.merge(updated_at: Time.current))
246
+ elsif row.pending?
247
+ row.update!(attributes)
248
+ end
231
249
  end
232
250
  end
233
251
  finish_fan_out!(run)
@@ -237,7 +255,7 @@ module ActionAgent
237
255
  # still completes.
238
256
  def fail_judging!(row, error)
239
257
  run = row.evaluation_run
240
- row.update!(status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
258
+ store_claimed_result!(row, status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
241
259
  finish_fan_out!(run)
242
260
  end
243
261
 
@@ -331,15 +349,59 @@ module ActionAgent
331
349
  @evaluation.scenarios.where(id: ids).count * specs.size
332
350
  end
333
351
 
334
- # Claims a pending result for this job before it is scored. Of two judge
335
- # jobs delivered for one result (a replay job delivered again after it
336
- # enqueued the first), only the one whose claim lands scores it, so the
337
- # judge is asked once and the run's meter counts each call once. The
338
- # claim is a conditional write on the row's timestamp: it holds no lock
339
- # while the judge is asked, and a row whose claimant then stops is still
340
- # pending for the next delivery.
352
+ # A timestamp comparison alone only rejects a stale reader. A duplicate
353
+ # delivered after that write reads the new timestamp and could score too.
354
+ # The lease excludes fresh readers as well, without holding a database
355
+ # lock across a provider call. A redelivery can reclaim an expired lease.
356
+ #
357
+ # Without the lease columns (migration 030 not yet run) the claim is the
358
+ # timestamp comparison alone, as before the lease.
341
359
  def claim_result!(row)
342
- EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at).update_all(updated_at: Time.current) == 1
360
+ now = Time.current
361
+ claim = EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at)
362
+ return claim.update_all(updated_at: now) == 1 unless scoring_leases?
363
+
364
+ token = SecureRandom.uuid
365
+ claimed = claim.where("scoring_token IS NULL OR scoring_expires_at <= ?", now)
366
+ .update_all(scoring_token: token, scoring_expires_at: now + JUDGE_LEASE_DURATION, updated_at: now)
367
+ @scoring_token = token if claimed == 1
368
+ end
369
+
370
+ # A claim lost to another delivery leaves the result pending, and that
371
+ # delivery may be a worker that stopped: the queue's redelivery of its
372
+ # job lands here, inside the lease the stopped worker still holds. So the
373
+ # job is enqueued again for when that lease expires. If the holder stores
374
+ # the result first, the later delivery finds it scored and only checks
375
+ # whether the run is complete. A result left pending with no live lease
376
+ # (the claim raced another write to the row) is tried again at once.
377
+ # Without the lease columns a lost claim is a concurrent delivery that is
378
+ # scoring the result now, as before.
379
+ def wait_for_claim!(row, run)
380
+ current = EvaluationScenarioResult.find_by(id: row.id) if scoring_leases?
381
+ if current&.pending?
382
+ retry_at = [ current.scoring_expires_at, Time.current ].compact.max
383
+ EvaluationJudgeJob.set(wait_until: retry_at).perform_later(row.id, @selection.to_h, run.id)
384
+ end
385
+ finish_fan_out!(run)
386
+ end
387
+
388
+ # Fence late successes and failures: after a lease is reclaimed, the old
389
+ # worker cannot overwrite its successor. Only the current owner releases
390
+ # the lease. A failure before claiming may mark an unclaimed row errored.
391
+ def store_claimed_result!(row, **attributes)
392
+ return row.update!(attributes) unless scoring_leases?
393
+
394
+ row.assign_attributes(attributes)
395
+ row.validate!
396
+ EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: @scoring_token)
397
+ .update_all(attributes.merge(scoring_token: nil, scoring_expires_at: nil, updated_at: Time.current))
398
+ end
399
+
400
+ # Whether migration 030's lease columns are installed. A host that
401
+ # upgraded the gem without running the generator keeps scoring with the
402
+ # timestamp claim instead of failing every judge job on a missing column.
403
+ def scoring_leases?
404
+ (%w[scoring_token scoring_expires_at] - EvaluationScenarioResult.column_names).empty?
343
405
  end
344
406
 
345
407
  # What Evals::Runner does with a replay that raises: an errored replay,
@@ -397,7 +459,7 @@ module ActionAgent
397
459
  available_tools: fan_out_state(run).fetch("tools") { tool_roster },
398
460
  instructions: @evaluation.agent.instructions,
399
461
  agent_name: @evaluation.agent.name,
400
- threshold: PASS_THRESHOLD,
462
+ **gating(run.pass_thresholds, Evals::Runner),
401
463
  replay: ->(*) { raise ArgumentError, "a fanned-out result is scored from its stored replay" },
402
464
  refine_gate: -> { reserve_refinement!(run) }
403
465
  )
@@ -457,7 +519,7 @@ module ActionAgent
457
519
  )
458
520
  end
459
521
  report = Evals::Report.new(results: results, models: specs.values, judge: evals_judge,
460
- instructions: @evaluation.agent.instructions, threshold: PASS_THRESHOLD)
522
+ instructions: @evaluation.agent.instructions, **gating(run.pass_thresholds, Evals::Report))
461
523
  state = fan_out_state(run)
462
524
 
463
525
  # The other jobs' judge calls go into this runner's meter before the
@@ -503,7 +565,7 @@ module ActionAgent
503
565
  error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
504
566
  end
505
567
 
506
- def default_report(tasks, specs, on_result)
568
+ def default_report(tasks, specs, on_result, run)
507
569
  Evals::Runner.new(
508
570
  scenarios: tasks,
509
571
  models: specs,
@@ -512,7 +574,7 @@ module ActionAgent
512
574
  available_tools: tool_roster,
513
575
  instructions: @evaluation.agent.instructions,
514
576
  agent_name: @evaluation.agent.name,
515
- threshold: PASS_THRESHOLD,
577
+ **gating(run.pass_thresholds, Evals::Runner),
516
578
  replay: ->(scenario, spec) { replay(scenario, spec) },
517
579
  on_result: on_result
518
580
  ).call
@@ -771,6 +833,7 @@ module ActionAgent
771
833
  comparison = report.comparison if report.respond_to?(:comparison)
772
834
  scores["_comparison"] = comparison if comparison
773
835
  scores["_selection"] = run.selection
836
+ record_gates(scores, report, run.pass_thresholds)
774
837
  scores["_metadata"] = report.metadata
775
838
  scores["_judge_label"] = report.judge_label || report.judge&.label
776
839
  # The judge's own spend, apart from the replays' (which the results
@@ -783,6 +846,60 @@ module ActionAgent
783
846
  scores
784
847
  end
785
848
 
849
+ # The bars a host adapter's report did not carry are the run's own, so
850
+ # the run is gated however its replays ran.
851
+ def record_gates(scores, report, recorded)
852
+ thresholds = (report.pass_thresholds if report.respond_to?(:pass_thresholds)) || recorded
853
+ return if thresholds.nil?
854
+
855
+ scores["_pass_thresholds"] = thresholds.to_h
856
+ scores["_gates"] = report.gates(thresholds).map(&:to_h)
857
+ end
858
+
859
+ # --- pass bars ----------------------------------------------------------
860
+
861
+ # The score keyword, and the bars when the run has them and the
862
+ # installed framework's +klass+ takes them: the bars' threshold is the
863
+ # catalog's or PASS_THRESHOLD, the score the dashboard always used.
864
+ def gating(thresholds, klass)
865
+ return { threshold: PASS_THRESHOLD } if thresholds.nil? || !accepts_keyword?(klass, :pass_thresholds)
866
+
867
+ { threshold: thresholds.threshold, pass_thresholds: thresholds }
868
+ end
869
+
870
+ def accepts_keyword?(klass, keyword)
871
+ klass.instance_method(:initialize).parameters.any? { |type, name| name == keyword && %i[key keyreq].include?(type) }
872
+ end
873
+
874
+ # Records the bars of the catalog set this evaluation runs as, once,
875
+ # as the run starts: a fanned-out replay reads them from the run.
876
+ def record_pass_thresholds!(run)
877
+ thresholds = catalog_pass_thresholds
878
+ return if thresholds.nil?
879
+
880
+ run.update!(scores: (run.scores.is_a?(Hash) ? run.scores : {}).merge("_pass_thresholds" => thresholds.to_h))
881
+ end
882
+
883
+ # The product's bars as the suite's and the set's as its group's, as
884
+ # Catalog#suite_for gives them, for an evaluation a catalog set
885
+ # materialized (ScenarioSet#catalog_reference) when the set, under the
886
+ # owner's catalogs, still has the keys the reference names; nil when
887
+ # it declares none, and on a framework without per-group bars.
888
+ def catalog_pass_thresholds
889
+ reference = @evaluation.config.to_h["catalog"]
890
+ return nil unless reference.is_a?(Hash) && defined?(Evals::PassThresholds)
891
+
892
+ set = ScenarioSet.joins(:product).includes(:product)
893
+ .where(ScenarioProduct.table_name => { scenario_catalog_id: ScenarioCatalog.for_owner(owner).select(:id) })
894
+ .find_by(id: reference["set_id"], key: reference["set_key"].to_s)
895
+ return nil if set.nil? || set.product.key != reference["product_key"].to_s
896
+ return nil if set.product.pass.empty? && set.pass.empty?
897
+
898
+ Evals::PassThresholds.new(suite: set.product.pass, groups: { set.key => set.pass })
899
+ rescue ArgumentError
900
+ nil
901
+ end
902
+
786
903
  # Records who judged once the calls are in. A judge with no model pinned
787
904
  # started out labelled by its provider; it is renamed after the model
788
905
  # that answered, here and on the verdict it wrote.
@@ -37,7 +37,9 @@ module ActionAgent
37
37
  # rules (single-user, per-user, or multi-tenant) decide what is visible.
38
38
  # The same goes for checkout sandbox runtimes: the caller hands in the live
39
39
  # ones it may see (SandboxSession.runtime_server_listings), and they are
40
- # listed beside the catalog under their "sandbox:<session_id>" keys.
40
+ # listed beside the catalog under their "sandbox:<session_id>" keys. The
41
+ # servers the owner added (MCPServerConfig.listings) are handed in the same
42
+ # way, under their "custom:<id>" keys.
41
43
  class ToolDiscovery
42
44
  DEFAULT_WINDOW_HOURS = 24 * 7
43
45
  MAX_WINDOW_HOURS = 24 * 90
@@ -543,6 +545,8 @@ module ActionAgent
543
545
  # from traffic is real but undocumented here, and the view says so.
544
546
  known: !catalog.nil?,
545
547
  runtime: runtime,
548
+ # A server the owner added on the MCP Services page ("custom:<id>").
549
+ custom: MCPServerConfig.key?(key),
546
550
  status: server_status(calls, configured, catalog),
547
551
  calls: calls,
548
552
  errors: bucket ? bucket[:errors] : 0,
data/config/routes.rb CHANGED
@@ -193,6 +193,12 @@ ActionAgent::Engine.routes.draw do
193
193
  end
194
194
  end
195
195
 
196
+ # The caller's own remote MCP servers, enabled on agents as
197
+ # "custom:<id>". Header values are write-only; test runs tools/list.
198
+ resources :mcp_server_configs, only: [ :index, :create, :update, :destroy ] do
199
+ post :test, on: :member
200
+ end
201
+
196
202
  resources :instance_tiers, only: [ :index, :show ] do
197
203
  collection do
198
204
  get :recommend
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The class body below reads Zeitwerk's inflector. Rails requires Zeitwerk
4
+ # only when an application sets up its autoloaders, which is after
5
+ # Bundler.require has loaded this file; a host that reaches it earlier does so
6
+ # by chance (Action Cable's engine loads it as a side effect). So the constant
7
+ # has to come from here. Zeitwerk is a dependency of Active Support, so it is
8
+ # always installed.
9
+ require "zeitwerk"
3
10
  require_relative "assistant_request_filter"
4
11
 
5
12
  module ActionAgent
@@ -25,7 +32,10 @@ module ActionAgent
25
32
  "mcp_tool_dispatcher" => "MCPToolDispatcher",
26
33
  "mcp_controller" => "MCPController",
27
34
  "mcp_dashboard_tools" => "MCPDashboardTools",
35
+ "mcp_host_guard" => "MCPHostGuard",
28
36
  "mcp_recording_middleware" => "MCPRecordingMiddleware",
37
+ "mcp_server_config" => "MCPServerConfig",
38
+ "mcp_server_configs_controller" => "MCPServerConfigsController",
29
39
  "mcp_servers_controller" => "MCPServersController",
30
40
  "playwright_mcp_client" => "PlaywrightMCPClient"
31
41
  }.freeze
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.9.6"
4
+ VERSION = "1.9.8"
5
5
  end
data/lib/action_agent.rb CHANGED
@@ -887,6 +887,22 @@ module ActionAgent
887
887
  # @return [Array<Hash>]
888
888
  attr_accessor :mcp_catalog
889
889
 
890
+ # Whether a dashboard user's own MCP server (MCPServerConfig, added on the
891
+ # MCP Services page) may point at a loopback, private, link-local or
892
+ # otherwise non-public address. Off by default: those servers are reached
893
+ # from the dashboard's own network, so a URL such as
894
+ # http://169.254.169.254/ or http://10.0.0.5/admin would let any user make
895
+ # the dashboard call an address only it can reach. MCPHostGuard refuses
896
+ # such a URL when it is saved, and refuses a host name that resolves to
897
+ # one when it is called. Turn it on for local development, where the
898
+ # server under test runs on localhost, or for a single-user install whose
899
+ # user already has that network access.
900
+ #
901
+ # Host registrations (+mcp_catalog+) and checkout sandbox runtimes are the
902
+ # host app's own configuration and are not affected.
903
+ # @return [Boolean]
904
+ attr_accessor :allow_private_mcp_hosts
905
+
890
906
  # Host-declared ActiveAgent::SchemaTools subclasses whose generated tools
891
907
  # are offered alongside AgentToolbox's built-ins: each generated tool is
892
908
  # individually selectable in the agent editor, dispatched by name at
@@ -1358,6 +1374,7 @@ module ActionAgent
1358
1374
  @user_menu_links = nil
1359
1375
  @sign_in_path = nil
1360
1376
  @mcp_catalog = []
1377
+ @allow_private_mcp_hosts = false
1361
1378
  @agent_actor_resolver = nil
1362
1379
  @schema_tools = nil
1363
1380
  @schema_tools_path = "app/agent_tools"
@@ -317,6 +317,17 @@ ActionAgent.configure do |config|
317
317
  # Expired sandboxes are stopped by `bin/rails action_agent:sandbox:reap`.
318
318
  # Schedule it every few minutes.
319
319
 
320
+ # ==========================================================================
321
+ # MCP servers
322
+ # ==========================================================================
323
+ #
324
+ # Users add their own remote MCP servers on the MCP Services page. Their
325
+ # URLs may not reach a loopback, private or link-local address unless this
326
+ # is on: those servers are called from the dashboard's own network. Turn it
327
+ # on in development to test a server running on localhost.
328
+ #
329
+ # config.allow_private_mcp_hosts = Rails.env.development?
330
+
320
331
  # ==========================================================================
321
332
  # UI
322
333
  # ==========================================================================
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddEvaluationScoringLeases < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_token, :string
6
+ add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_expires_at, :datetime
7
+ end
8
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCatalogScenarioMetadata < ActiveRecord::Migration<%= migration_version %>
4
+ def up
5
+ return unless table_exists?(scenarios_table)
6
+ return if column_exists?(scenarios_table, :metadata)
7
+
8
+ type = connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json
9
+ add_column scenarios_table, :metadata, type
10
+ end
11
+
12
+ def down
13
+ return unless table_exists?(scenarios_table)
14
+ return unless column_exists?(scenarios_table, :metadata)
15
+
16
+ remove_column scenarios_table, :metadata
17
+ end
18
+
19
+ private
20
+
21
+ def scenarios_table
22
+ "#{ActionAgent.table_name_prefix}catalog_scenarios"
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MCP servers a dashboard user adds on the MCP Services page
4
+ # (ActionAgent::MCPServerConfig): a remote server's URL, its request headers
5
+ # (encrypted at rest, in secret_headers), and what the last connection test
6
+ # found. Each row belongs to an account or a user, as provider keys do.
7
+ #
8
+ # Table names follow ActionAgent.table_name_prefix, and JSON columns are
9
+ # jsonb on PostgreSQL and json elsewhere, the same way as
10
+ # create_active_agent_dashboard_tables.
11
+ class CreateActiveAgentMCPServerConfigs < ActiveRecord::Migration<%= migration_version %>
12
+ def change
13
+ prefix = ActionAgent.table_name_prefix
14
+
15
+ create_table "#{prefix}mcp_server_configs" do |t|
16
+ t.string :name, null: false
17
+ t.text :description
18
+ t.string :url, null: false, limit: 2048
19
+ # A JSON object of header name => value, encrypted.
20
+ t.text :secret_headers
21
+ t.boolean :enabled, null: false, default: true
22
+ t.datetime :last_tested_at
23
+ # "ok" or "failed", and the error of a failed test.
24
+ t.string :last_test_status
25
+ t.text :last_test_error
26
+ # The tool names the last successful test listed.
27
+ t.column :tool_names, json_type, **json_default([])
28
+ t.bigint :account_id
29
+ t.bigint :user_id
30
+ t.timestamps
31
+ t.index :account_id, name: "index_#{prefix}mcp_server_configs_on_account"
32
+ t.index :user_id, name: "index_#{prefix}mcp_server_configs_on_user"
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def json_type
39
+ @json_type ||= postgres? ? :jsonb : :json
40
+ end
41
+
42
+ # MySQL rejects a default on a JSON column outright, so the column is
43
+ # created without one there.
44
+ def json_default(value, null: nil)
45
+ return {} unless postgres?
46
+
47
+ null.nil? ? { default: value } : { default: value, null: null }
48
+ end
49
+
50
+ def postgres?
51
+ connection.adapter_name.to_s.downcase.include?("postgres")
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCatalogProductCriteria < ActiveRecord::Migration<%= migration_version %>
4
+ def up
5
+ return unless table_exists?(products_table)
6
+ return if column_exists?(products_table, :criteria)
7
+
8
+ type = connection.adapter_name.to_s.downcase.include?("postgres") ? :jsonb : :json
9
+ add_column products_table, :criteria, type
10
+ end
11
+
12
+ def down
13
+ return unless table_exists?(products_table)
14
+ return unless column_exists?(products_table, :criteria)
15
+
16
+ remove_column products_table, :criteria
17
+ end
18
+
19
+ private
20
+
21
+ def products_table
22
+ "#{ActionAgent.table_name_prefix}scenario_products"
23
+ end
24
+ end