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.
- checksums.yaml +4 -4
- data/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +48 -44
- data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
- data/app/controllers/action_agent/api/agents_controller.rb +76 -48
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +14 -5
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +182 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +111 -3
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/services/action_agent/agent_execution_service.rb +11 -3
- data/app/services/action_agent/agent_toolbox.rb +28 -5
- data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/playwright_mcp_client.rb +6 -6
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +25 -2
- data/lib/action_agent/engine.rb +136 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +91 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- 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
|
|
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
|
-
|
|
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 =
|
|
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 · #{
|
|
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 = (
|
|
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 =
|
|
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
|
-
# (
|
|
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
|
data/lib/action_agent/engine.rb
CHANGED
|
@@ -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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# ActionAgent::
|
|
35
|
-
#
|
|
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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
|
84
|
+
next super(basename, abspath) unless abspath.to_s.start_with?(engine_root)
|
|
47
85
|
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/action_agent/version.rb
CHANGED
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
202
|
-
# { "cloud_run" => "CloudRunService" }. The engine ships :mock
|
|
203
|
-
#
|
|
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 = :
|
|
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
|
-
|
|
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
|
-
"
|
|
72
|
-
"db/migrate/
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|