actionagent 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +48 -44
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
  6. data/app/controllers/action_agent/api/agents_controller.rb +76 -48
  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/evaluations_controller.rb +237 -7
  10. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  11. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  12. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  13. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  14. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  15. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  16. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  17. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  18. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  19. data/app/controllers/action_agent/application_controller.rb +25 -2
  20. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  21. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  22. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  23. data/app/jobs/action_agent/application_job.rb +7 -3
  24. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  25. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  26. data/app/models/action_agent/agent.rb +14 -5
  27. data/app/models/action_agent/agent_template.rb +22 -7
  28. data/app/models/action_agent/evaluation.rb +64 -4
  29. data/app/models/action_agent/evaluation_run.rb +182 -2
  30. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  31. data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
  32. data/app/models/action_agent/recording_action.rb +11 -7
  33. data/app/models/action_agent/sandbox_session.rb +1 -1
  34. data/app/models/action_agent/session_recording.rb +31 -8
  35. data/app/models/action_agent/telemetry_trace.rb +111 -3
  36. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  37. data/app/models/concerns/action_agent/ownable.rb +15 -2
  38. data/app/queries/action_agent/metrics_report.rb +498 -0
  39. data/app/services/action_agent/agent_execution_service.rb +11 -3
  40. data/app/services/action_agent/agent_toolbox.rb +28 -5
  41. data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
  42. data/app/services/action_agent/mcp_catalog.rb +46 -8
  43. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  44. data/app/services/action_agent/playwright_mcp_client.rb +6 -6
  45. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  46. data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
  47. data/app/services/action_agent/tool_discovery.rb +22 -8
  48. data/config/routes.rb +25 -2
  49. data/lib/action_agent/engine.rb +136 -19
  50. data/lib/action_agent/version.rb +1 -1
  51. data/lib/action_agent.rb +91 -6
  52. data/lib/generators/action_agent/install_generator.rb +20 -7
  53. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  54. data/lib/tasks/action_agent.rake +9 -0
  55. metadata +19 -6
@@ -30,7 +30,7 @@ module ActionAgent
30
30
  #
31
31
  # MCP attribution comes from ActiveAgent::Telemetry::ToolOrigin (the
32
32
  # +mcp__server__tool+ convention, tagged onto spans at instrumentation
33
- # time), then McpCatalog's hints for bare tool names, then the tool is
33
+ # time), then MCPCatalog's hints for bare tool names, then the tool is
34
34
  # treated as a method the agent class defines.
35
35
  #
36
36
  # Scopes are passed in rather than derived, so the caller's ownership
@@ -259,7 +259,7 @@ module ActionAgent
259
259
  end
260
260
  end
261
261
 
262
- Array(agent.mcp_servers).each do |server|
262
+ configured_mcp_servers(agent).each do |server|
263
263
  key = mcp_server_key(server)
264
264
  next if key.blank?
265
265
 
@@ -404,7 +404,7 @@ module ActionAgent
404
404
  classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
405
405
  return { origin: ORIGIN_MCP, server: classification[:server] } if classification[:server].present?
406
406
 
407
- if (hinted = McpCatalog.server_for_tool(name))
407
+ if (hinted = MCPCatalog.server_for_tool(name))
408
408
  # A catalog hint is weaker evidence than a namespaced name: the tool
409
409
  # is *probably* this server's, but a builtin of the same name is the
410
410
  # dashboard's own implementation, so builtins win the tie.
@@ -474,7 +474,7 @@ module ActionAgent
474
474
 
475
475
  def source_label(origin, server)
476
476
  case origin
477
- when ORIGIN_MCP then server.present? ? "MCP · #{McpCatalog.display_name(server)}" : "MCP"
477
+ when ORIGIN_MCP then server.present? ? "MCP · #{MCPCatalog.display_name(server)}" : "MCP"
478
478
  when ORIGIN_BUILTIN then "Dashboard toolbox"
479
479
  else "Agent-defined"
480
480
  end
@@ -500,7 +500,7 @@ module ActionAgent
500
500
  end
501
501
  end
502
502
 
503
- keys = (McpCatalog::BY_KEY.keys + detected.keys + configured_servers.keys).uniq
503
+ keys = (MCPCatalog.keys + detected.keys + configured_servers.keys).uniq
504
504
 
505
505
  # detected has a default block that would materialize a bucket on
506
506
  # lookup, so unseen servers are passed through as an explicit nil.
@@ -509,7 +509,7 @@ module ActionAgent
509
509
  end
510
510
 
511
511
  def server_row(key, bucket)
512
- catalog = McpCatalog.find(key)
512
+ catalog = MCPCatalog.find(key)
513
513
  configured = configured_servers[key].to_a.sort
514
514
  calls = bucket ? bucket[:calls] : 0
515
515
 
@@ -559,11 +559,25 @@ module ActionAgent
559
559
  @configured_servers ||= Hash.new { |hash, key| hash[key] = Set.new }
560
560
  end
561
561
 
562
+ # The servers an agent declares, as a list. Agents store an Array, but
563
+ # an agent created from an older template seed carried a top-level Hash
564
+ # keyed by server name ({"playwright" => {"command" => ...}}); Array()
565
+ # turned that into [key, value] pairs and the key lookup below raised
566
+ # TypeError on the Array, taking down /api/tools and /api/mcp_servers
567
+ # for the whole workspace.
568
+ def configured_mcp_servers(agent)
569
+ servers = agent.mcp_servers
570
+ return servers.keys if servers.is_a?(Hash)
571
+
572
+ Array(servers)
573
+ end
574
+
562
575
  # An agent's mcp_servers entries are free-form: a bare string name, or a
563
576
  # hash from the builder ({"name" => "playwright", "url" => ...}).
577
+ # Anything else (a stray Array, a number) is skipped rather than raised on.
564
578
  def mcp_server_key(server)
565
- return server.to_s.strip if server.is_a?(String)
566
- return nil unless server.respond_to?(:[])
579
+ return server.to_s.strip.presence if server.is_a?(String) || server.is_a?(Symbol)
580
+ return nil unless server.respond_to?(:key?)
567
581
 
568
582
  (server["key"] || server[:key] || server["name"] || server[:name]).to_s.strip.presence
569
583
  end
data/config/routes.rb CHANGED
@@ -70,7 +70,7 @@ ActionAgent::Engine.routes.draw do
70
70
  resources :tools, only: [ :index ]
71
71
 
72
72
  # MCP services — detected servers unioned with the default catalog
73
- # (McpCatalog), plus on-demand sandbox provisioning. Keys are catalog
73
+ # (MCPCatalog), plus on-demand sandbox provisioning. Keys are catalog
74
74
  # slugs like "sequential-thinking", so the id segment allows dashes.
75
75
  resources :mcp_servers, only: [ :index, :show ], id: /[^\/]+/ do
76
76
  member do
@@ -113,10 +113,17 @@ ActionAgent::Engine.routes.draw do
113
113
  # Conversations (contexts, messages, generations) behind Interactions.
114
114
  resources :interactions, only: [ :index, :show ]
115
115
 
116
- # Agent output evaluations.
116
+ # Agent output evaluations. A scenario suite also manages its scenarios
117
+ # here, and exposes each run's per-scenario, per-model results.
117
118
  resources :evaluations, only: [ :index, :show, :create, :destroy ] do
118
119
  member do
119
120
  post :run
121
+ get "runs/:run_id", action: :show_run, as: :run_result
122
+ get "runs/:run_id/report", action: :run_report, as: :run_report
123
+ get :scenarios
124
+ put :scenarios, action: :replace_scenarios
125
+ patch "scenarios/:scenario_id", action: :update_scenario, as: :scenario
126
+ delete "scenarios/:scenario_id", action: :destroy_scenario
120
127
  end
121
128
  end
122
129
 
@@ -128,6 +135,12 @@ ActionAgent::Engine.routes.draw do
128
135
  # Model catalogs for the agent builder (Ollama queried live from the
129
136
  # configured host; hosted providers curated).
130
137
  resources :provider_models, only: [ :index ]
138
+
139
+ # The plan meter the Organization view and the Run Agents quota banner
140
+ # read. The engine meters nothing itself: a host that tracks usage
141
+ # against a plan answers through ActionAgent.usage_resolver, and a bare
142
+ # mount reports unlimited rather than 404.
143
+ resource :usage, only: [ :show ], controller: "usage"
131
144
  end
132
145
 
133
146
  # The account's agents presented as an authenticated MCP server (tools +
@@ -136,6 +149,16 @@ ActionAgent::Engine.routes.draw do
136
149
  # namespace's session-authenticated controllers.
137
150
  post "mcp", to: "api/mcp#create"
138
151
 
152
+ # MCP Streamable HTTP (2025-03-26): a client MAY open the server-to-client
153
+ # SSE stream with GET, and ends a session with DELETE. This facade offers
154
+ # no stream and keeps no sessions, so both answer 405 with Allow: POST —
155
+ # the clean "not offered" signal SDK clients expect, instead of the
156
+ # dashboard's HTML page parsed as an event stream. A browser's GET (Accept
157
+ # prefers HTML) is the MCP Services view's deep link, and falls through to
158
+ # the catch-all below like any other client-side route.
159
+ match "mcp", to: "api/mcp#unsupported", via: [ :get, :delete ],
160
+ constraints: ->(request) { request.delete? || !ActionAgent::Engine.html_request?(request) }
161
+
139
162
  # Everything else under the mount is a client-side route: render the
140
163
  # dashboard and let the browser resolve it. Anchored last so it can only
141
164
  # ever catch what the routes above did not, and refuses /api paths so a
@@ -13,8 +13,38 @@ module ActionAgent
13
13
 
14
14
  engine_name "action_agent"
15
15
 
16
+ # Basenames this engine spells differently from Zeitwerk's default
17
+ # camelization, consulted by the inflections initializer below. An engine
18
+ # file that wants a genuine acronym in its constant adds its basename here
19
+ # rather than relying on the host to register one.
20
+ INFLECTION_OVERRIDES = {
21
+ "mcp_catalog" => "MCPCatalog",
22
+ "mcp_controller" => "MCPController",
23
+ "mcp_recording_middleware" => "MCPRecordingMiddleware",
24
+ "mcp_servers_controller" => "MCPServersController",
25
+ "playwright_mcp_client" => "PlaywrightMCPClient"
26
+ }.freeze
27
+
28
+ # The spelling default camelization produces for each overridden file,
29
+ # mapped to the constant the file actually defines. The const_missing shim
30
+ # below uses it to answer lookups that camelized with default inflections
31
+ # (a plain host's router, or caller code written against the pre-acronym
32
+ # names) with the acronym constant.
33
+ DEFAULT_SPELLINGS = INFLECTION_OVERRIDES.to_h { |basename, constant|
34
+ [ Zeitwerk::Inflector.new.camelize(basename, nil), constant ]
35
+ }.freeze
36
+
16
37
  config.action_agent = ActiveSupport::OrderedOptions.new
17
38
 
39
+ # Whether a request is a browser asking for a page, as opposed to an API
40
+ # or MCP client: the routes use it to tell the dashboard's client-side
41
+ # deep links apart from protocol traffic on the same path.
42
+ def self.html_request?(request)
43
+ Array(request.accepts).any? { |type| type.respond_to?(:html?) && type.html? }
44
+ rescue StandardError
45
+ false
46
+ end
47
+
18
48
  # The dashboard's JS and CSS ship prebuilt in the gem. Adding the
19
49
  # directory to the host app's asset paths is what lets a plain
20
50
  # `mount ActionAgent::Engine` work without the host running a
@@ -26,26 +56,34 @@ module ActionAgent
26
56
  app.config.filter_parameters += [ :credential, :api_key, :access_token ]
27
57
  end
28
58
 
29
- # The controllers under app/controllers/action_agent/api are ActionAgent::Api,
30
- # but an engine's files are autoloaded by the host's `rails.main` loader,
31
- # under the host's inflections. A host that declares `inflect.acronym "API"`
32
- # common enough that Rails documents it makes Zeitwerk expect
33
- # ActionAgent::API::TracesController in a file that defines
34
- # ActionAgent::Api::TracesController, and every request to the mount raises
35
- # Zeitwerk::NameError.
59
+ # This engine's constants are spelled the way Zeitwerk's own inflector
60
+ # spells them Api, ApiKey but an engine's files are
61
+ # autoloaded by the host's `rails.main` loader, under the *host's*
62
+ # inflections. A host that declares `inflect.acronym "API"` or "MCP" (both
63
+ # common, and documented by Rails) makes Zeitwerk expect
64
+ # ActionAgent::API::TracesController from a file
65
+ # that defines ActionAgent::Api::TracesController. The constant never
66
+ # resolves and the request raises Zeitwerk::NameError.
67
+ #
68
+ # Every path under this engine therefore camelizes with Zeitwerk's default
69
+ # rules, ignoring whatever acronyms the host has registered. Applied by
70
+ # path rather than through `inflect`: the loader is shared with the host,
71
+ # so a blanket rule would re-spell the host's own constants. `camelize`
72
+ # receives the absolute path, which is the only hook that can tell this
73
+ # engine's files from the host's.
36
74
  #
37
- # Scoped to this engine's own path rather than set through `inflect`: the
38
- # loader is shared, so a blanket rule would re-spell the host's own API
39
- # constants. `camelize` receives the absolute path, which is the only hook
40
- # that can tell this engine's api/ from the host's.
75
+ # Basenames whose spelling this engine cannot express through default
76
+ # camelization (a genuine acronym it wants uppercased) go in
77
+ # INFLECTION_OVERRIDES.
41
78
  initializer "action_agent.inflections", before: :set_autoload_paths do
42
- engine_root = root.to_s
79
+ engine_root = File.join(root.to_s, "")
80
+ default = Zeitwerk::Inflector.new
43
81
 
44
82
  Rails.autoloaders.main.inflector.singleton_class.prepend(Module.new do
45
83
  define_method(:camelize) do |basename, abspath|
46
- next "Api" if basename == "api" && abspath.to_s.start_with?(engine_root)
84
+ next super(basename, abspath) unless abspath.to_s.start_with?(engine_root)
47
85
 
48
- super(basename, abspath)
86
+ ActionAgent::Engine::INFLECTION_OVERRIDES.fetch(basename) { default.camelize(basename, abspath) }
49
87
  end
50
88
  end)
51
89
  end
@@ -59,19 +97,98 @@ module ActionAgent
59
97
  # So the namespace answers to both. `const_missing` rather than an eager
60
98
  # alias because the controllers are autoloaded on demand, and naming them at
61
99
  # boot would load the whole dashboard.
62
- ActionAgent.singleton_class.prepend(Module.new do
100
+ # The router does not consult the autoloader's inflector, so its lookups
101
+ # miss in both directions. An acronym host asks for
102
+ # ActionAgent::API::TracesController while the constant is
103
+ # Api::TracesController: an all-caps run in a missing constant is retried
104
+ # in the spelling default camelization produces (API -> Api). A plain host
105
+ # asks for Api::McpServersController while the constant is
106
+ # MCPServersController (the file is in INFLECTION_OVERRIDES): a missing
107
+ # constant matching an override's default spelling is retried as the
108
+ # acronym constant, via DEFAULT_SPELLINGS — which also keeps caller code
109
+ # written against the pre-acronym names resolving. Only the engine's own
110
+ # namespaces are touched, and only for a constant that is already missing.
111
+ inflection_shim = Module.new do
63
112
  def const_missing(name)
64
- return const_get(:Api) if name == :API
113
+ acronym = ActionAgent::Engine::DEFAULT_SPELLINGS[name.to_s]
114
+ return const_get(acronym, false) if acronym && const_defined?(acronym, false)
115
+
116
+ relaxed = name.to_s.gsub(/([A-Z])([A-Z]+)(?=[A-Z][a-z]|\d|\z)/) { "#{$1}#{$2.downcase}" }
65
117
 
66
- super
118
+ return super if relaxed == name.to_s || !const_defined?(relaxed, false)
119
+
120
+ const_get(relaxed, false)
67
121
  end
68
- end)
122
+ end
123
+
124
+ ActionAgent.singleton_class.prepend(inflection_shim)
125
+
126
+ # ActionAgent::Api is autoloaded, so it cannot be reopened at this point.
127
+ # Zeitwerk hands it over as soon as it is defined, which is before the
128
+ # router can ask it for a controller.
129
+ initializer "action_agent.api_inflection_shim" do
130
+ Rails.autoloaders.main.on_load("ActionAgent::Api") do |mod, _abspath|
131
+ mod.singleton_class.prepend(inflection_shim)
132
+ end
133
+ end
134
+
135
+ # API keys and provider credentials are encrypted at rest (`encrypts` on
136
+ # ApiKey and ProviderKey), which needs Active Record Encryption keys the
137
+ # host app may never have generated — `rails db:encryption:init` is a
138
+ # step most installs skip, and the engine's own reference host had
139
+ # skipped it too. Without keys, every credential write and every
140
+ # authenticated MCP request raised Errors::Configuration as an HTML 500.
141
+ #
142
+ # Rails seeds ActiveRecord::Encryption from credentials and
143
+ # config.active_record.encryption in its own
144
+ # "active_record_encryption.configuration" initializer. Keys derived
145
+ # after that one land in config but never reach the encryptor, so the
146
+ # config reads back correct while every credential write raises
147
+ # Errors::Configuration — which is what a host that skipped
148
+ # db:encryption:init actually hits. Naming it in `before:` is what puts
149
+ # the derivation ahead of it. When the host set no keys — neither here
150
+ # nor in credentials — derive stable ones from secret_key_base, overridable
151
+ # through the ACTIVE_RECORD_ENCRYPTION_* variables, exactly as the
152
+ # activeagents.ai platform does. Host-provided keys are never
153
+ # overridden, and the derivation is stable as long as secret_key_base is.
154
+ initializer "action_agent.active_record_encryption", before: "active_record_encryption.configuration" do |app|
155
+ next unless ActionAgent.encrypt_credentials
156
+ next unless app.config.respond_to?(:active_record)
157
+
158
+ encryption = app.config.active_record.encryption
159
+ configured = %i[primary_key deterministic_key key_derivation_salt].any? { |key| encryption[key].present? }
160
+ configured ||= begin
161
+ app.credentials.dig(:active_record_encryption, :primary_key).present?
162
+ rescue StandardError
163
+ false
164
+ end
165
+ next if configured
166
+
167
+ derive = lambda do |purpose|
168
+ app.key_generator.generate_key("active_record_encryption.#{purpose}", 32).unpack1("H*")
169
+ end
170
+
171
+ encryption.primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].presence || derive.call("primary_key")
172
+ encryption.deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].presence || derive.call("deterministic_key")
173
+ encryption.key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].presence || derive.call("key_derivation_salt")
174
+ end
69
175
 
70
176
  initializer "action_agent.assets", before: :append_assets_path do |app|
71
177
  builds = root.join("app", "assets", "builds").to_s
72
178
  next unless File.directory?(builds)
73
179
 
74
- next unless app.config.respond_to?(:assets)
180
+ # The prebuilt bundles are served through the host's asset pipeline —
181
+ # propshaft (the Rails default) or sprockets-rails. A host without one
182
+ # (a `rails --api` app, or one that removed propshaft) used to get a
183
+ # silently blank dashboard with two 404s; say why instead.
184
+ unless app.config.respond_to?(:assets)
185
+ Rails.logger&.warn(
186
+ "[ActionAgent] the dashboard's assets cannot be served: this app has no asset pipeline. " \
187
+ "Add propshaft (or sprockets-rails) to the Gemfile so action_agent.js and action_agent.css " \
188
+ "are served from the engine's app/assets/builds."
189
+ )
190
+ next
191
+ end
75
192
 
76
193
  if app.config.assets.respond_to?(:paths)
77
194
  app.config.assets.paths << builds unless app.config.assets.paths.include?(builds)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/action_agent.rb CHANGED
@@ -20,6 +20,25 @@ module ActionAgent
20
20
  global = defined?(::ActiveRecord::Base) ? ::ActiveRecord::Base.table_name_prefix : ""
21
21
  "#{global}#{@table_name_prefix ||= "active_agent_"}"
22
22
  end
23
+
24
+ # Which keyword the installed solid_agent uses to switch has_context's
25
+ # auto-context off: `contextable:` up to 0.1, `contextual:` from 0.2. The
26
+ # gemspec floor admits both, and passing the wrong one raises an
27
+ # ArgumentError deep inside a run rather than at boot — so
28
+ # AgentExecutionService asks rather than assumes.
29
+ #
30
+ # Covered by test/integration/solid_agent, which runs this engine against
31
+ # solid_agent's main branch as well as the released gem.
32
+ def solid_agent_auto_context_keyword
33
+ @solid_agent_auto_context_keyword ||= begin
34
+ keywords = ::SolidAgent::HasContext::ClassMethods
35
+ .instance_method(:has_context).parameters
36
+ .select { |type, _| [ :key, :keyreq ].include?(type) }
37
+ .map(&:last)
38
+
39
+ keywords.include?(:contextual) ? :contextual : :contextable
40
+ end
41
+ end
23
42
  end
24
43
  end
25
44
 
@@ -64,7 +83,10 @@ require "action_agent/compatibility"
64
83
  #
65
84
  # ActionAgent.configure do |config|
66
85
  # config.authentication_method = ->(controller) { controller.authenticate_admin! }
67
- # config.sandbox_service = :local # Docker/Incus
86
+ # # Sandboxes run in the in-memory mock unless the app registers a real
87
+ # # backend (see sandbox_backends) and names it here:
88
+ # config.sandbox_backends = { "incus" => "IncusSandboxService" }
89
+ # config.sandbox_service = :incus
68
90
  # end
69
91
  #
70
92
  # == Multi-tenant Mode
@@ -152,7 +174,10 @@ module ActionAgent
152
174
  # @return [String, nil]
153
175
  attr_accessor :layout
154
176
 
155
- # Sandbox service type (:local, :cloud_run, :kubernetes)
177
+ # Which sandbox backend to provision with: :mock (the only one the
178
+ # engine ships — an in-memory fake that runs nothing) or the name of a
179
+ # backend the host registered in sandbox_backends. An unregistered name
180
+ # falls back to :mock with a logged warning.
156
181
  # @return [Symbol]
157
182
  attr_accessor :sandbox_service
158
183
 
@@ -198,9 +223,11 @@ module ActionAgent
198
223
  # @return [Proc, nil]
199
224
  attr_accessor :provider_credentials_resolver
200
225
 
201
- # Extra sandbox backends contributed by the host app, as
202
- # { "cloud_run" => "CloudRunService" }. The engine ships :mock and
203
- # :local (Docker); cloud backends live in the app that operates them.
226
+ # Sandbox backends contributed by the host app, as
227
+ # { "cloud_run" => "CloudRunService" }. The engine ships only :mock;
228
+ # every real backend (Docker/Incus, Cloud Run, Kubernetes) lives in the
229
+ # app that operates it, which registers it here and selects it with
230
+ # sandbox_service.
204
231
  # @return [Hash{String => String}]
205
232
  attr_accessor :sandbox_backends
206
233
 
@@ -215,6 +242,37 @@ module ActionAgent
215
242
  # @return [String, nil]
216
243
  attr_accessor :upgrade_url
217
244
 
245
+ # The host app's sign-out endpoint, which the header's "Sign out" item
246
+ # POSTs to (with _method=delete and the CSRF token). The engine has no
247
+ # session of its own; unset, the menu item is not shown.
248
+ # @return [String, nil]
249
+ attr_accessor :sign_out_path
250
+
251
+ # Where a browser is sent when it asks for a dashboard page without a
252
+ # valid session — the host app's sign-in page. Unset, an unauthenticated
253
+ # page request gets a minimal session-expired page instead of a bare
254
+ # 401; API clients always get the 401.
255
+ # @return [String, nil]
256
+ attr_accessor :sign_in_path
257
+
258
+ # Answers GET <mount>/api/usage — the plan meter the Organization view
259
+ # and the Run Agents quota banner read. Receives (owner) and returns a
260
+ # Hash in the platform's shape:
261
+ #
262
+ # { runs_used: 12, runs_limit: 100, runs_remaining: 88,
263
+ # can_run: true, plan: "pro" }
264
+ #
265
+ # Unset means unlimited: the engine reports UNLIMITED_USAGE and the
266
+ # views hide the meter.
267
+ # @return [Proc, nil]
268
+ attr_accessor :usage_resolver
269
+
270
+ # What a dashboard with no usage_resolver reports: no limit, nothing
271
+ # counted, always allowed.
272
+ UNLIMITED_USAGE = {
273
+ runs_used: 0, runs_limit: nil, runs_remaining: nil, can_run: true, plan: nil, unlimited: true
274
+ }.freeze
275
+
218
276
  # Called after the dashboard performs a metered action, as
219
277
  # (owner, kind) — the counterpart to quota_checker, for host apps that
220
278
  # track usage against a plan. Unset means nothing is counted.
@@ -243,6 +301,16 @@ module ActionAgent
243
301
  # @return [Boolean]
244
302
  attr_accessor :encrypt_credentials
245
303
 
304
+ # MCP servers the host app itself serves or connects, appended to the
305
+ # built-in catalog (MCPCatalog) so the MCP Services view lists them and
306
+ # telemetry traffic attributes to them. Each entry is a hash shaped like
307
+ # a catalog entry: +key+ and +name+ at minimum, plus any of the optional
308
+ # fields (+description+, +transport+, +url+, +categories+, +docs_url+,
309
+ # +first_party+); +tool_hints+ names the bare tool names that belong to
310
+ # the server. A built-in entry keeps its key on collision.
311
+ # @return [Array<Hash>]
312
+ attr_accessor :mcp_catalog
313
+
246
314
  # Value stored in polymorphic *_type columns for dashboard agents
247
315
  # (agent_memories.memorable_type, agent_contexts.contextable_type).
248
316
  # Unset means the class name. A host app whose existing rows were
@@ -273,6 +341,19 @@ module ActionAgent
273
341
  nil
274
342
  end
275
343
 
344
+ # The usage meter for +owner+. Never raises: a bookkeeping failure must
345
+ # not take the views that display it down with it.
346
+ #
347
+ # @return [Hash] the platform's usage shape, UNLIMITED_USAGE by default
348
+ def usage_for(owner)
349
+ return UNLIMITED_USAGE.dup if usage_resolver.nil?
350
+
351
+ usage_resolver.call(owner) || UNLIMITED_USAGE.dup
352
+ rescue StandardError => e
353
+ Rails.logger.warn("[ActionAgent] usage lookup failed: #{e.message}")
354
+ UNLIMITED_USAGE.dup
355
+ end
356
+
276
357
  # Asks the host app whether +owner+ may perform +kind+.
277
358
  #
278
359
  # @return [String, Hash, nil] denial message or payload, nil when allowed
@@ -362,7 +443,7 @@ module ActionAgent
362
443
  @trace_model_class = nil
363
444
  @use_inertia = false
364
445
  @layout = nil
365
- @sandbox_service = :local
446
+ @sandbox_service = :mock
366
447
  @sandbox_limits = nil
367
448
  @storage_service = nil
368
449
  @ingest_api_key = nil
@@ -377,7 +458,11 @@ module ActionAgent
377
458
  @trace_retention = nil
378
459
  @trace_owner_resolver = nil
379
460
  @usage_recorder = nil
461
+ @usage_resolver = nil
380
462
  @upgrade_url = nil
463
+ @sign_out_path = nil
464
+ @sign_in_path = nil
465
+ @mcp_catalog = []
381
466
  end
382
467
  end
383
468
 
@@ -64,12 +64,20 @@ module ActionAgent
64
64
 
65
65
  if existing_migration?("create_active_agent_dashboard_tables")
66
66
  say_status :skip, "create_active_agent_dashboard_tables already exists", :yellow
67
- return
67
+ else
68
+ migration_template(
69
+ "create_active_agent_dashboard_tables.rb.erb",
70
+ "db/migrate/create_active_agent_dashboard_tables.rb"
71
+ )
68
72
  end
69
73
 
74
+ # Scenario suites arrived after the dashboard tables shipped, so an
75
+ # install that already has those still needs this one.
76
+ return if existing_migration?("create_active_agent_evaluation_scenarios")
77
+
70
78
  migration_template(
71
- "create_active_agent_dashboard_tables.rb.erb",
72
- "db/migrate/create_active_agent_dashboard_tables.rb"
79
+ "create_active_agent_evaluation_scenarios.rb.erb",
80
+ "db/migrate/create_active_agent_evaluation_scenarios.rb"
73
81
  )
74
82
  end
75
83
 
@@ -102,10 +110,15 @@ module ActionAgent
102
110
  say "\n"
103
111
  say "Next steps:"
104
112
  say " 1. Run migrations: rails db:migrate"
105
- say " 2. Configure telemetry in config/active_agent.yml:"
106
- say " telemetry:"
107
- say " enabled: true"
108
- say " local_storage: true"
113
+ # Nested under the environment key: ActiveAgent::Configuration.load
114
+ # reads only the current environment's subtree when the file has one,
115
+ # and the framework's own generator writes an env-keyed file — so a
116
+ # top-level telemetry: block is silently ignored.
117
+ say " 2. Configure telemetry in config/active_agent.yml, under each environment key:"
118
+ say " development:"
119
+ say " telemetry:"
120
+ say " enabled: true"
121
+ say " local_storage: true"
109
122
  say " 3. Visit /activeagents to view the dashboard"
110
123
  unless options[:traces_only]
111
124
  say "\n"
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds scenario suites to evaluations: the user-authored prompts an
4
+ # evaluation replays through its agent (once per candidate model), and the
5
+ # per-scenario, per-model result each replay produced. Emitted alongside the
6
+ # dashboard tables on a fresh install, and on its own for an install that
7
+ # predates scenario evaluations.
8
+ #
9
+ # Table names follow ActionAgent.table_name_prefix, and JSON columns are
10
+ # jsonb on PostgreSQL and json elsewhere, the same way as
11
+ # create_active_agent_dashboard_tables.
12
+ class CreateActiveAgentEvaluationScenarios < ActiveRecord::Migration<%= migration_version %>
13
+ def change
14
+ prefix = ActionAgent.table_name_prefix
15
+
16
+ create_table "#{prefix}evaluation_scenarios" do |t|
17
+ t.bigint :evaluation_id, null: false
18
+ t.string :key, null: false
19
+ t.string :group
20
+ t.text :prompt, null: false
21
+ t.text :notes
22
+ t.column :expectations, json_type, **json_default({})
23
+ t.integer :position, default: 0, null: false
24
+ t.boolean :enabled, default: true, null: false
25
+ t.timestamps
26
+ t.index [ :evaluation_id, :key ], unique: true, name: "index_#{prefix}evaluation_scenarios_on_evaluation_and_key"
27
+ t.index [ :evaluation_id, :group ], name: "index_#{prefix}evaluation_scenarios_on_evaluation_and_group"
28
+ end
29
+
30
+ create_table "#{prefix}evaluation_scenario_results" do |t|
31
+ t.bigint :evaluation_run_id, null: false
32
+ t.bigint :evaluation_scenario_id, null: false
33
+ t.bigint :agent_run_id
34
+ t.string :model, null: false
35
+ t.string :provider
36
+ t.integer :status, default: 0, null: false
37
+ t.float :score
38
+ t.column :scores, json_type, **json_default({})
39
+ t.text :output
40
+ t.column :tool_calls, json_type, **json_default([])
41
+ t.integer :duration_ms
42
+ t.integer :input_tokens
43
+ t.integer :output_tokens
44
+ t.decimal :cost, precision: 12, scale: 6
45
+ t.string :fault
46
+ t.text :recommendation
47
+ t.column :diagnosis, json_type, **json_default({})
48
+ t.text :error_message
49
+ t.timestamps
50
+ t.index :evaluation_run_id, name: "index_#{prefix}evaluation_scenario_results_on_run"
51
+ t.index :evaluation_scenario_id, name: "index_#{prefix}evaluation_scenario_results_on_scenario"
52
+ t.index [ :evaluation_run_id, :model ], name: "index_#{prefix}evaluation_scenario_results_on_run_and_model"
53
+ end
54
+
55
+ # Which scenarios and models a run covered, so a partial run (one group,
56
+ # one question) reads as such rather than as the whole suite.
57
+ unless column_exists?("#{prefix}evaluation_runs", :selection)
58
+ add_column "#{prefix}evaluation_runs", :selection, json_type, **json_default({})
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def json_type
65
+ @json_type ||= postgres? ? :jsonb : :json
66
+ end
67
+
68
+ # MySQL rejects a default on a JSON column outright, so the column is
69
+ # created without one there.
70
+ def json_default(value, null: nil)
71
+ return {} unless postgres?
72
+
73
+ null.nil? ? { default: value } : { default: value, null: null }
74
+ end
75
+
76
+ def postgres?
77
+ connection.adapter_name.to_s.downcase.include?("postgres")
78
+ end
79
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :action_agent do
4
+ desc "Seed the dashboard's default agent templates (idempotent — existing slugs are kept)"
5
+ task seed_templates: :environment do
6
+ ActionAgent::AgentTemplate.seed_defaults!
7
+ puts "Agent template library: #{ActionAgent::AgentTemplate.count} templates"
8
+ end
9
+ end