actionagent 1.9.6 → 1.9.7

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 (46) 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 +61 -60
  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/catalog_scenario.rb +8 -2
  16. data/app/models/action_agent/evaluation_run.rb +11 -3
  17. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  18. data/app/models/action_agent/mcp_server_config.rb +273 -0
  19. data/app/models/action_agent/model_pricing.rb +135 -24
  20. data/app/models/action_agent/telemetry_trace.rb +35 -14
  21. data/app/queries/action_agent/agent_executions.rb +4 -1
  22. data/app/queries/action_agent/metrics_report.rb +1 -1
  23. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  24. data/app/services/action_agent/agent_execution_service.rb +4 -2
  25. data/app/services/action_agent/agent_scorecard.rb +16 -12
  26. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  27. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  28. data/app/services/action_agent/evaluation_report_import.rb +14 -2
  29. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  30. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  31. data/app/services/action_agent/mcp_client.rb +36 -4
  32. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  33. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  34. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  35. data/app/services/action_agent/scenario_catalog_import.rb +2 -0
  36. data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
  37. data/app/services/action_agent/tool_discovery.rb +5 -1
  38. data/config/routes.rb +6 -0
  39. data/lib/action_agent/engine.rb +10 -0
  40. data/lib/action_agent/version.rb +1 -1
  41. data/lib/action_agent.rb +17 -0
  42. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  43. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  44. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  45. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  46. metadata +8 -2
@@ -41,6 +41,7 @@ module ActionAgent
41
41
  # any replay, and one started here is stopped once the run ends.
42
42
  class ScenarioEvaluationRunner < EvaluationRunnerService
43
43
  Evals = ActiveAgent::Evals
44
+ JUDGE_LEASE_DURATION = 30.minutes
44
45
 
45
46
  # The providers a candidate model name resolves against. `mock` is the
46
47
  # framework's test double, accepted so the test suite can compare cohorts
@@ -187,17 +188,20 @@ module ActionAgent
187
188
  # A job delivered again for a result that is already scored, or that a
188
189
  # concurrent delivery has claimed, scores nothing. It still checks
189
190
  # whether the run is complete: the earlier delivery may have stored the
190
- # last result and stopped before completing the run.
191
+ # last result and stopped before completing the run. A result another
192
+ # delivery holds a live lease on is tried again when the lease expires
193
+ # (see #wait_for_claim!).
191
194
  def judge_result!(row)
192
195
  run = row.evaluation_run
193
196
  return unless run.running?
194
- return finish_fan_out!(run) unless row.pending? && claim_result!(row)
197
+ return finish_fan_out!(run) unless row.pending?
198
+ return wait_for_claim!(row, run) unless claim_result!(row)
195
199
 
196
200
  spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do
197
201
  Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model)
198
202
  end
199
203
  result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row))
200
- row.update!(
204
+ store_claimed_result!(row,
201
205
  status: result.status,
202
206
  score: result.score,
203
207
  scores: result.scores,
@@ -227,7 +231,15 @@ module ActionAgent
227
231
  row = stored_result(run, scenario, spec)
228
232
  task = Evals::Scenario.from_hash(scenario.as_json_summary)
229
233
  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?
234
+ attributes = { status: :errored, error_message: row.error_message.presence || error_text(error) }
235
+ if scoring_leases?
236
+ # Another replay delivery may fail while this result is being
237
+ # scored. Only an unclaimed replay belongs to this failure path.
238
+ EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: nil)
239
+ .update_all(attributes.merge(updated_at: Time.current))
240
+ elsif row.pending?
241
+ row.update!(attributes)
242
+ end
231
243
  end
232
244
  end
233
245
  finish_fan_out!(run)
@@ -237,7 +249,7 @@ module ActionAgent
237
249
  # still completes.
238
250
  def fail_judging!(row, error)
239
251
  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?
252
+ store_claimed_result!(row, status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
241
253
  finish_fan_out!(run)
242
254
  end
243
255
 
@@ -331,15 +343,59 @@ module ActionAgent
331
343
  @evaluation.scenarios.where(id: ids).count * specs.size
332
344
  end
333
345
 
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.
346
+ # A timestamp comparison alone only rejects a stale reader. A duplicate
347
+ # delivered after that write reads the new timestamp and could score too.
348
+ # The lease excludes fresh readers as well, without holding a database
349
+ # lock across a provider call. A redelivery can reclaim an expired lease.
350
+ #
351
+ # Without the lease columns (migration 030 not yet run) the claim is the
352
+ # timestamp comparison alone, as before the lease.
341
353
  def claim_result!(row)
342
- EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at).update_all(updated_at: Time.current) == 1
354
+ now = Time.current
355
+ claim = EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at)
356
+ return claim.update_all(updated_at: now) == 1 unless scoring_leases?
357
+
358
+ token = SecureRandom.uuid
359
+ claimed = claim.where("scoring_token IS NULL OR scoring_expires_at <= ?", now)
360
+ .update_all(scoring_token: token, scoring_expires_at: now + JUDGE_LEASE_DURATION, updated_at: now)
361
+ @scoring_token = token if claimed == 1
362
+ end
363
+
364
+ # A claim lost to another delivery leaves the result pending, and that
365
+ # delivery may be a worker that stopped: the queue's redelivery of its
366
+ # job lands here, inside the lease the stopped worker still holds. So the
367
+ # job is enqueued again for when that lease expires. If the holder stores
368
+ # the result first, the later delivery finds it scored and only checks
369
+ # whether the run is complete. A result left pending with no live lease
370
+ # (the claim raced another write to the row) is tried again at once.
371
+ # Without the lease columns a lost claim is a concurrent delivery that is
372
+ # scoring the result now, as before.
373
+ def wait_for_claim!(row, run)
374
+ current = EvaluationScenarioResult.find_by(id: row.id) if scoring_leases?
375
+ if current&.pending?
376
+ retry_at = [ current.scoring_expires_at, Time.current ].compact.max
377
+ EvaluationJudgeJob.set(wait_until: retry_at).perform_later(row.id, @selection.to_h, run.id)
378
+ end
379
+ finish_fan_out!(run)
380
+ end
381
+
382
+ # Fence late successes and failures: after a lease is reclaimed, the old
383
+ # worker cannot overwrite its successor. Only the current owner releases
384
+ # the lease. A failure before claiming may mark an unclaimed row errored.
385
+ def store_claimed_result!(row, **attributes)
386
+ return row.update!(attributes) unless scoring_leases?
387
+
388
+ row.assign_attributes(attributes)
389
+ row.validate!
390
+ EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: @scoring_token)
391
+ .update_all(attributes.merge(scoring_token: nil, scoring_expires_at: nil, updated_at: Time.current))
392
+ end
393
+
394
+ # Whether migration 030's lease columns are installed. A host that
395
+ # upgraded the gem without running the generator keeps scoring with the
396
+ # timestamp claim instead of failing every judge job on a missing column.
397
+ def scoring_leases?
398
+ (%w[scoring_token scoring_expires_at] - EvaluationScenarioResult.column_names).empty?
343
399
  end
344
400
 
345
401
  # What Evals::Runner does with a replay that raises: an errored replay,
@@ -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.7"
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.6
4
+ version: 1.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-30 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activeagent
@@ -150,6 +150,7 @@ files:
150
150
  - app/controllers/action_agent/api/interaction_messages_controller.rb
151
151
  - app/controllers/action_agent/api/interactions_controller.rb
152
152
  - app/controllers/action_agent/api/mcp_controller.rb
153
+ - app/controllers/action_agent/api/mcp_server_configs_controller.rb
153
154
  - app/controllers/action_agent/api/mcp_servers_controller.rb
154
155
  - app/controllers/action_agent/api/members_controller.rb
155
156
  - app/controllers/action_agent/api/metrics_controller.rb
@@ -229,6 +230,7 @@ files:
229
230
  - app/models/action_agent/github_webhook_delivery.rb
230
231
  - app/models/action_agent/input_request.rb
231
232
  - app/models/action_agent/interaction_feedback.rb
233
+ - app/models/action_agent/mcp_server_config.rb
232
234
  - app/models/action_agent/model_pricing.rb
233
235
  - app/models/action_agent/project.rb
234
236
  - app/models/action_agent/project_secret.rb
@@ -297,6 +299,7 @@ files:
297
299
  - app/services/action_agent/local_sandbox_databases.rb
298
300
  - app/services/action_agent/mcp_catalog.rb
299
301
  - app/services/action_agent/mcp_client.rb
302
+ - app/services/action_agent/mcp_host_guard.rb
300
303
  - app/services/action_agent/mcp_recording_middleware.rb
301
304
  - app/services/action_agent/mcp_tool_dispatcher.rb
302
305
  - app/services/action_agent/mock_sandbox_backend.rb
@@ -377,6 +380,9 @@ files:
377
380
  - lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb
378
381
  - lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb
379
382
  - lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb
383
+ - lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb
384
+ - lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb
385
+ - lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb
380
386
  - lib/tasks/action_agent.rake
381
387
  - lib/tasks/browser.rake
382
388
  - lib/tasks/claude_code.rake