actionagent 1.7.2 → 1.8.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/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +59 -54
- data/app/controllers/action_agent/api/agents_controller.rb +19 -3
- data/app/controllers/action_agent/api/code_sessions_controller.rb +156 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +27 -130
- data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
- data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
- data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
- data/app/controllers/action_agent/api/sandboxes_controller.rb +97 -6
- data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +507 -0
- data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
- data/app/jobs/action_agent/code_session_job.rb +166 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
- data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
- data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
- data/app/models/action_agent/agent.rb +16 -6
- data/app/models/action_agent/agent_run.rb +20 -1
- data/app/models/action_agent/code_session.rb +141 -0
- data/app/models/action_agent/evaluation_run.rb +23 -1
- data/app/models/action_agent/github_connection.rb +75 -0
- data/app/models/action_agent/provider_key.rb +142 -10
- data/app/models/action_agent/sandbox_session.rb +193 -17
- data/app/serializers/action_agent/evaluation_serializer.rb +118 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_tool_roster.rb +20 -9
- data/app/services/action_agent/claude_code_auth.rb +86 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
- data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
- data/app/services/action_agent/github_client.rb +111 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1689 -0
- data/app/services/action_agent/local_sandbox_databases.rb +257 -0
- data/app/services/action_agent/mcp_client.rb +5 -1
- data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
- data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
- data/app/services/action_agent/ollama_host_probe.rb +75 -0
- data/app/services/action_agent/payload_bounds.rb +36 -0
- data/app/services/action_agent/sandbox_manifest.rb +67 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +69 -14
- data/app/services/action_agent/scenario_evaluation_runner.rb +50 -4
- data/app/services/action_agent/secret_scrubber.rb +37 -0
- data/app/services/action_agent/tool_discovery.rb +19 -5
- data/config/routes.rb +22 -3
- data/lib/action_agent/engine.rb +1 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +147 -3
- data/lib/generators/action_agent/install_generator.rb +30 -3
- data/lib/generators/action_agent/templates/action_agent.rb.erb +44 -0
- data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
- data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
- data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
- data/lib/tasks/claude_code.rake +16 -0
- data/lib/tasks/sandbox.rake +26 -0
- metadata +23 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# The database a :local sandbox boots on, unless its sandbox.yml says
|
|
5
|
+
# otherwise. A checkout of the app the dashboard itself runs names the same
|
|
6
|
+
# development database in its config/database.yml, and its `db:prepare`
|
|
7
|
+
# would migrate the developer's own; so every sandbox gets databases of its
|
|
8
|
+
# own, through the variables Rails merges over database.yml
|
|
9
|
+
# (ActiveRecord::DatabaseConfigurations#environment_value_for):
|
|
10
|
+
# DATABASE_URL for the `primary` entry, <NAME>_DATABASE_URL for any other
|
|
11
|
+
# (QUEUE_DATABASE_URL for Solid Queue's `queue`).
|
|
12
|
+
#
|
|
13
|
+
# Only the adapter and database names are read, from the checkout's
|
|
14
|
+
# config/database.yml, and nothing of the checkout runs in the dashboard:
|
|
15
|
+
# ERB tags are blanked out rather than evaluated, and the YAML is read with
|
|
16
|
+
# safe_load. What is left over is enough to know the adapter, which is
|
|
17
|
+
# all a URL needs; the host, user and password stay whatever database.yml
|
|
18
|
+
# or the process environment (PGHOST and the like) say, because Rails
|
|
19
|
+
# merges a URL over the entry rather than replacing it.
|
|
20
|
+
#
|
|
21
|
+
# sqlite3 sqlite3:<workspace>/db/<env>[_<name>].sqlite3, removed with the workspace
|
|
22
|
+
# postgresql/postgis postgresql:///<database>_sandbox_<short id>, dropped on terminate
|
|
23
|
+
# mysql2/trilogy mysql2:///<database>_sandbox_<short id>, dropped on terminate
|
|
24
|
+
#
|
|
25
|
+
# Any variable the sandbox.yml env sets is left to it.
|
|
26
|
+
class LocalSandboxDatabases
|
|
27
|
+
CONFIG_PATH = File.join("config", "database.yml")
|
|
28
|
+
# Larger than any database.yml; a checkout could point the path anywhere.
|
|
29
|
+
MAX_CONFIG_BYTES = 256 * 1024
|
|
30
|
+
# Stands in for every <%= %> output. Not an identifier, so a database
|
|
31
|
+
# name built from ERB is never mistaken for a real one.
|
|
32
|
+
ERB_OUTPUT = "(erb)"
|
|
33
|
+
ERB_TAG = /<%(?!%)(=|-|\#)?.*?-?%>/m
|
|
34
|
+
FILE_ADAPTERS = %w[sqlite3].freeze
|
|
35
|
+
SERVER_ADAPTERS = %w[postgresql postgis mysql2 trilogy].freeze
|
|
36
|
+
# PostgreSQL cuts identifiers at 63 bytes, MySQL at 64.
|
|
37
|
+
MAX_DATABASE_NAME = 63
|
|
38
|
+
IDENTIFIER = /\A[A-Za-z0-9_]+\z/
|
|
39
|
+
ENTRY_NAME = /\A[A-Za-z][A-Za-z0-9_]*\z/
|
|
40
|
+
|
|
41
|
+
# What the backend does with a checkout's databases.
|
|
42
|
+
#
|
|
43
|
+
# env the variables to add to every boot step's environment
|
|
44
|
+
# drop_targets the named server database URLs terminate may drop
|
|
45
|
+
# notes one line per decision, for the setup log
|
|
46
|
+
Plan = Struct.new(:env, :drop_targets, :rails_env, :notes, keyword_init: true) do
|
|
47
|
+
def drop
|
|
48
|
+
drop_targets.any?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
env.empty?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param app [Pathname] the checkout
|
|
57
|
+
# @param workspace [Pathname] the sandbox's workspace
|
|
58
|
+
# @param session_id [String]
|
|
59
|
+
# @param overrides [Hash] the sandbox.yml env: what it sets is its own
|
|
60
|
+
# @param fallback_name [String, nil] a database name base when
|
|
61
|
+
# database.yml gives none (the repository's name)
|
|
62
|
+
# @return [Plan]
|
|
63
|
+
def self.plan(app:, workspace:, session_id:, overrides: {}, fallback_name: nil)
|
|
64
|
+
new(app: app, workspace: workspace, session_id: session_id, overrides: overrides, fallback_name: fallback_name).plan
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(app:, workspace:, session_id:, overrides:, fallback_name:)
|
|
68
|
+
@app = Pathname(app)
|
|
69
|
+
@workspace = Pathname(workspace)
|
|
70
|
+
@session_id = session_id.to_s
|
|
71
|
+
@overrides = overrides.to_h
|
|
72
|
+
@fallback_name = fallback_name
|
|
73
|
+
@notes = []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def plan
|
|
77
|
+
env = {}
|
|
78
|
+
drop_targets = {}
|
|
79
|
+
entries = read_entries || []
|
|
80
|
+
urls = {}
|
|
81
|
+
|
|
82
|
+
# Resolve writers before their replicas, independent of YAML order.
|
|
83
|
+
entries.sort_by { |_name, config| truthy?(config["replica"]) ? 1 : 0 }.each do |name, config|
|
|
84
|
+
variable = database_variable(name)
|
|
85
|
+
if @overrides.key?(variable)
|
|
86
|
+
urls[name] = @overrides[variable] unless config.key?("url")
|
|
87
|
+
note("#{variable}: left to .activeagents/sandbox.yml")
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
url, server = url_for(name, config, entries, urls)
|
|
92
|
+
next unless url
|
|
93
|
+
|
|
94
|
+
urls[name] = url
|
|
95
|
+
env[variable] = url
|
|
96
|
+
drop_targets[name] = url if server
|
|
97
|
+
note("#{variable}=#{url}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# db:prepare and db:drop in development also reach the test
|
|
101
|
+
# database, unless DATABASE_URL is set; that one is the developer's.
|
|
102
|
+
env["SKIP_TEST_DATABASE"] = "1" if env.any? && !@overrides.key?("SKIP_TEST_DATABASE")
|
|
103
|
+
Plan.new(env: env, drop_targets: drop_targets, rails_env: rails_env, notes: @notes)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def rails_env
|
|
109
|
+
@overrides["RAILS_ENV"].presence || @overrides["RACK_ENV"].presence || "development"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# [[name, config], ...] for the Rails environment the sandbox boots in,
|
|
113
|
+
# or nil when database.yml cannot say.
|
|
114
|
+
def read_entries
|
|
115
|
+
text = read_config
|
|
116
|
+
return nil unless text
|
|
117
|
+
|
|
118
|
+
data = begin
|
|
119
|
+
YAML.safe_load(text.gsub(ERB_TAG) { ::Regexp.last_match(1) == "=" ? ERB_OUTPUT : "" }, aliases: true)
|
|
120
|
+
rescue Psych::Exception
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
return adapter_from_text(text) unless data.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
config = data[rails_env]
|
|
126
|
+
case config
|
|
127
|
+
when Hash
|
|
128
|
+
if config.any? && config.values.all?(Hash)
|
|
129
|
+
config.select { |name, _| ENTRY_NAME.match?(name.to_s) }.map { |name, entry| [ name.to_s, entry ] }
|
|
130
|
+
else
|
|
131
|
+
[ [ "primary", config ] ]
|
|
132
|
+
end
|
|
133
|
+
when String
|
|
134
|
+
note("#{CONFIG_PATH}: #{rails_env} is a URL, which Rails does not let DATABASE_URL override; set it in .activeagents/sandbox.yml")
|
|
135
|
+
nil
|
|
136
|
+
else
|
|
137
|
+
note("#{CONFIG_PATH}: no #{rails_env} configuration")
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A config that does not parse once its ERB is blanked (a tag that
|
|
143
|
+
# emits whole keys) still names its adapter somewhere: the first
|
|
144
|
+
# `adapter:` line, as one primary database.
|
|
145
|
+
def adapter_from_text(text)
|
|
146
|
+
adapter = text[/^\s*adapter:\s*["']?([A-Za-z0-9_]+)/, 1]
|
|
147
|
+
unless adapter
|
|
148
|
+
note("#{CONFIG_PATH}: could not be read without running its ERB; no sandbox database set")
|
|
149
|
+
return nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
[ [ "primary", { "adapter" => adapter } ] ]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read_config
|
|
156
|
+
path = @app.join(CONFIG_PATH)
|
|
157
|
+
return nil unless path.file?
|
|
158
|
+
|
|
159
|
+
# Read only inside the checkout: a symlink could name any file the
|
|
160
|
+
# dashboard's user can read.
|
|
161
|
+
real = path.realpath
|
|
162
|
+
unless real.to_s.start_with?("#{@app.realpath}#{File::SEPARATOR}")
|
|
163
|
+
note("#{CONFIG_PATH}: points outside the checkout; not read")
|
|
164
|
+
return nil
|
|
165
|
+
end
|
|
166
|
+
if real.size > MAX_CONFIG_BYTES
|
|
167
|
+
note("#{CONFIG_PATH}: too large to read")
|
|
168
|
+
return nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
real.read.force_encoding(Encoding::UTF_8).scrub
|
|
172
|
+
rescue SystemCallError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# [url, server?] for one entry, or nil when it is left alone.
|
|
177
|
+
def url_for(name, config, entries, urls)
|
|
178
|
+
unless config.is_a?(Hash)
|
|
179
|
+
note("#{name}: not a mapping; left alone")
|
|
180
|
+
return nil
|
|
181
|
+
end
|
|
182
|
+
if config.key?("url")
|
|
183
|
+
# A url: entry is a UrlConfig, which Rails merges no variable over.
|
|
184
|
+
note("#{name}: has its own url:, which Rails does not let a variable override; set it in .activeagents/sandbox.yml")
|
|
185
|
+
return nil
|
|
186
|
+
end
|
|
187
|
+
if config.key?("database_tasks") && !truthy?(config["database_tasks"])
|
|
188
|
+
note("#{name}: database_tasks is off (a database the app does not manage); left alone")
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
191
|
+
return [ replica_url(name, config, entries, urls), false ] if truthy?(config["replica"])
|
|
192
|
+
|
|
193
|
+
adapter = config["adapter"].to_s
|
|
194
|
+
if FILE_ADAPTERS.include?(adapter)
|
|
195
|
+
[ sqlite_url(name), false ]
|
|
196
|
+
elsif SERVER_ADAPTERS.include?(adapter)
|
|
197
|
+
[ "#{adapter}:///#{database_name(name, config)}", true ]
|
|
198
|
+
else
|
|
199
|
+
what = adapter.empty? || adapter == ERB_OUTPUT ? "its adapter is unknown" : "adapter #{adapter} is not one it knows"
|
|
200
|
+
note("#{name}: #{what}; no sandbox database set")
|
|
201
|
+
Rails.logger.info("[ActionAgent] sandbox #{@session_id}: #{CONFIG_PATH} #{name}: #{what}; its database is left as configured")
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Hostnames can differ between a writer and its read replica. Match the
|
|
207
|
+
# adapter and literal database name, but refuse ambiguous or ERB-derived
|
|
208
|
+
# identities instead of silently reading another database.
|
|
209
|
+
def replica_url(name, config, entries, urls)
|
|
210
|
+
database = config["database"].to_s
|
|
211
|
+
matches = if database.present? && !database.include?(ERB_OUTPUT)
|
|
212
|
+
entries.select do |_candidate, entry|
|
|
213
|
+
!truthy?(entry["replica"]) && entry["adapter"] == config["adapter"] && entry["database"] == database
|
|
214
|
+
end
|
|
215
|
+
else
|
|
216
|
+
[]
|
|
217
|
+
end
|
|
218
|
+
url = urls[matches.first.first] if matches.one?
|
|
219
|
+
return url if url.present?
|
|
220
|
+
|
|
221
|
+
raise ArgumentError, "#{CONFIG_PATH}: cannot identify a unique sandbox database for replica #{name}; " \
|
|
222
|
+
"set #{database_variable(name)} in .activeagents/sandbox.yml"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def database_variable(name)
|
|
226
|
+
name == "primary" ? "DATABASE_URL" : "#{name.upcase}_DATABASE_URL"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def truthy?(value)
|
|
230
|
+
value == true || value.to_s == "true"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Inside the workspace, so it goes when the workspace does.
|
|
234
|
+
def sqlite_url(name)
|
|
235
|
+
FileUtils.mkdir_p(@workspace.join("db"), mode: 0o700)
|
|
236
|
+
file = @workspace.join("db", name == "primary" ? "#{rails_env}.sqlite3" : "#{rails_env}_#{name}.sqlite3")
|
|
237
|
+
"sqlite3:#{URI::RFC2396_Parser.new.escape(file.to_s)}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# <database>_sandbox_<first 8 of the session id>: recognizably the
|
|
241
|
+
# app's, and one per sandbox.
|
|
242
|
+
def database_name(name, config)
|
|
243
|
+
base = config["database"].to_s
|
|
244
|
+
unless IDENTIFIER.match?(base)
|
|
245
|
+
app = @fallback_name.to_s.gsub(/[^A-Za-z0-9_]/, "_").presence || "app"
|
|
246
|
+
base = [ app, rails_env, (name unless name == "primary") ].compact.join("_")
|
|
247
|
+
end
|
|
248
|
+
suffix = "_sandbox_#{@session_id.delete("-").downcase.first(8)}"
|
|
249
|
+
"#{base.first(MAX_DATABASE_NAME - suffix.length)}#{suffix}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def note(line)
|
|
253
|
+
@notes << line
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -17,9 +17,12 @@ module ActionAgent
|
|
|
17
17
|
|
|
18
18
|
class Error < StandardError; end
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# +headers+ are sent with every request: a checkout sandbox's runtime
|
|
21
|
+
# authenticates its MCP facade with a bearer token.
|
|
22
|
+
def initialize(url:, label: nil, headers: {})
|
|
21
23
|
@uri = URI(url)
|
|
22
24
|
@label = label.presence || @uri.host
|
|
25
|
+
@headers = headers.to_h
|
|
23
26
|
@mutex = Mutex.new
|
|
24
27
|
end
|
|
25
28
|
|
|
@@ -113,6 +116,7 @@ module ActionAgent
|
|
|
113
116
|
request["Content-Type"] = "application/json"
|
|
114
117
|
request["Accept"] = "application/json, text/event-stream"
|
|
115
118
|
request["Mcp-Session-Id"] = session if session
|
|
119
|
+
@headers.each { |name, value| request[name.to_s] = value }
|
|
116
120
|
request.body = payload.to_json
|
|
117
121
|
|
|
118
122
|
response = http.request(request)
|
|
@@ -4,23 +4,53 @@ module ActionAgent
|
|
|
4
4
|
# Routes a tool call to the MCP server that serves it.
|
|
5
5
|
#
|
|
6
6
|
# An agent names the servers it uses in +mcp_servers+, and a catalog entry
|
|
7
|
-
# carries the url to reach one over HTTP. A tool the agent's servers claim
|
|
8
|
-
#
|
|
9
|
-
# engine's
|
|
7
|
+
# carries the url to reach one over HTTP. A tool the agent's servers claim,
|
|
8
|
+
# and that the agent's entry for that server does not switch off, is called
|
|
9
|
+
# there; anything else returns nil, and the caller falls back to the engine's
|
|
10
|
+
# own AgentToolbox.
|
|
10
11
|
#
|
|
11
12
|
# Only HTTP transports are dispatchable. A stdio server runs as a child
|
|
12
13
|
# process of whatever launched it, so the dashboard has no address to call —
|
|
13
14
|
# those stay listable and attributable without being callable.
|
|
15
|
+
#
|
|
16
|
+
# A single run can also reach a checkout sandbox's app runtime the agent
|
|
17
|
+
# does not name (+extra_server_keys+, "sandbox:<session_id>"): evaluating
|
|
18
|
+
# an agent against a checkout someone is experimenting in, without saving
|
|
19
|
+
# that sandbox on the agent. Such a key is treated as if the agent had it
|
|
20
|
+
# enabled, and resolves exactly as a saved one does: through
|
|
21
|
+
# SandboxSession.runtime_server_entry, among the agent's owner's sessions,
|
|
22
|
+
# and only while it is live. Only runtime keys are taken; a catalog server
|
|
23
|
+
# the agent does not enable stays out of reach.
|
|
14
24
|
class MCPToolDispatcher
|
|
15
25
|
HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
|
|
26
|
+
class SandboxUnavailable < MCPClient::Error; end
|
|
27
|
+
|
|
28
|
+
attr_reader :extra_server_keys
|
|
29
|
+
|
|
30
|
+
# A runtime explicitly selected for this run must not silently disappear
|
|
31
|
+
# between enqueue, discovery and a tool call.
|
|
32
|
+
def ensure_extra_servers_live!
|
|
33
|
+
extra_server_keys.each do |key|
|
|
34
|
+
sandbox = SandboxSession.for_owner(agent.try(:owner)).find_by(session_id: key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX))
|
|
35
|
+
next if sandbox && (sandbox.ready? || sandbox.running?) && sandbox.runtime_server_entry
|
|
36
|
+
|
|
37
|
+
raise SandboxUnavailable, "Sandbox #{key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)} is no longer running; " \
|
|
38
|
+
"start it again, or run without it"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
16
41
|
|
|
17
|
-
def initialize(agent)
|
|
42
|
+
def initialize(agent, extra_server_keys: [])
|
|
18
43
|
@agent = agent
|
|
19
44
|
@resolver = EvaluationToolResolver.new(agent)
|
|
45
|
+
@extra_server_keys = Array(extra_server_keys).map { |key| key.to_s.strip }
|
|
46
|
+
.select { |key| SandboxSession.runtime_server_key?(key) }.uniq
|
|
20
47
|
@clients = {}
|
|
48
|
+
@listed_by = {}
|
|
21
49
|
end
|
|
22
50
|
|
|
23
|
-
# Whether this tool belongs to one of the agent's own reachable servers
|
|
51
|
+
# Whether this tool belongs to one of the agent's own reachable servers —
|
|
52
|
+
# by its name, or because one of them listed it in the last
|
|
53
|
+
# +tool_definitions+.
|
|
24
54
|
def dispatchable?(tool_name)
|
|
25
55
|
endpoint_for(tool_name).present?
|
|
26
56
|
end
|
|
@@ -28,8 +58,8 @@ module ActionAgent
|
|
|
28
58
|
# Whether the agent names any server the dashboard can call. An agent with
|
|
29
59
|
# none has nothing to execute beyond the engine's own toolbox.
|
|
30
60
|
def any_reachable_server?
|
|
31
|
-
|
|
32
|
-
entry =
|
|
61
|
+
server_keys.any? do |key|
|
|
62
|
+
entry = catalog_entry(key)
|
|
33
63
|
entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
|
|
34
64
|
end
|
|
35
65
|
end
|
|
@@ -41,6 +71,7 @@ module ActionAgent
|
|
|
41
71
|
#
|
|
42
72
|
# @return [Hash, nil] nil when no configured server claims the tool
|
|
43
73
|
def call(tool_name, arguments = {})
|
|
74
|
+
ensure_extra_servers_live!
|
|
44
75
|
endpoint = endpoint_for(tool_name)
|
|
45
76
|
return nil unless endpoint
|
|
46
77
|
|
|
@@ -63,16 +94,37 @@ module ActionAgent
|
|
|
63
94
|
# from a server that legitimately serves no tools — and an agent offered no
|
|
64
95
|
# tools answers from the model alone, which reads as a confident, fabricated
|
|
65
96
|
# result rather than a transport failure (#425).
|
|
97
|
+
#
|
|
98
|
+
# A server entry that carries an allow-list ({key, tools: [...]}, which the
|
|
99
|
+
# Tools tab saves when tools are switched off) contributes only the tools
|
|
100
|
+
# it names: a tool switched off is not offered.
|
|
101
|
+
#
|
|
102
|
+
# A sandbox explicitly selected for a run takes precedence for duplicate
|
|
103
|
+
# names and must answer discovery; its failure aborts the run.
|
|
104
|
+
#
|
|
105
|
+
# Each tool offered is also remembered against the server that listed it,
|
|
106
|
+
# which is how a call finds its way back (see endpoint_for).
|
|
66
107
|
def tool_definitions
|
|
108
|
+
ensure_extra_servers_live!
|
|
67
109
|
@discovery_errors = {}
|
|
110
|
+
@listed_by = {}
|
|
68
111
|
|
|
69
|
-
|
|
70
|
-
entry =
|
|
112
|
+
server_keys.flat_map do |key|
|
|
113
|
+
entry = catalog_entry(key)
|
|
71
114
|
next [] unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
|
|
72
115
|
|
|
73
116
|
begin
|
|
74
|
-
client_for(entry).list_tools
|
|
117
|
+
client_for(entry).list_tools.select do |tool|
|
|
118
|
+
name = (tool[:name] || tool["name"]).to_s
|
|
119
|
+
next false if name.blank? || listed_by.key?(name) || !allowed?(key, name)
|
|
120
|
+
|
|
121
|
+
@listed_by[name] = key
|
|
122
|
+
true
|
|
123
|
+
end
|
|
75
124
|
rescue MCPClient::Error => e
|
|
125
|
+
if extra_server_keys.include?(key)
|
|
126
|
+
raise SandboxUnavailable, "Cannot load tools from selected sandbox #{key.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)}: #{e.message}"
|
|
127
|
+
end
|
|
76
128
|
Rails.logger.warn("[MCPToolDispatcher] #{key} tools/list failed: #{e.message}")
|
|
77
129
|
@discovery_errors[key] =
|
|
78
130
|
"Cannot load tools from MCP server '#{key}' (#{entry[:url]}): #{e.message}. " \
|
|
@@ -95,8 +147,8 @@ module ActionAgent
|
|
|
95
147
|
# execution that follows cannot produce a meaningful result, so a caller
|
|
96
148
|
# can fail loudly instead of scoring an answer the model invented.
|
|
97
149
|
def all_servers_failed?
|
|
98
|
-
keys =
|
|
99
|
-
entry =
|
|
150
|
+
keys = server_keys.select do |key|
|
|
151
|
+
entry = catalog_entry(key)
|
|
100
152
|
entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
|
|
101
153
|
end
|
|
102
154
|
|
|
@@ -105,28 +157,96 @@ module ActionAgent
|
|
|
105
157
|
|
|
106
158
|
private
|
|
107
159
|
|
|
108
|
-
attr_reader :agent, :resolver
|
|
160
|
+
attr_reader :agent, :resolver, :listed_by
|
|
161
|
+
|
|
162
|
+
# The selected checkout replaces matching tools from the agent's normal
|
|
163
|
+
# servers. Discovery and dispatch must agree on that precedence.
|
|
164
|
+
def server_keys
|
|
165
|
+
declared = resolver.declared_server_keys
|
|
166
|
+
(extra_server_keys + declared).uniq { |key| normalize(key) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Whether the agent enabled +key+, or this run was given it.
|
|
170
|
+
def enabled?(key)
|
|
171
|
+
resolver.status_for(key) == EvaluationToolResolver::ENABLED ||
|
|
172
|
+
extra_server_keys.any? { |extra| normalize(extra) == normalize(key) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize(key)
|
|
176
|
+
key.to_s.strip.downcase
|
|
177
|
+
end
|
|
109
178
|
|
|
110
179
|
# The catalog entry for the server that serves this tool, but only when the
|
|
111
180
|
# agent configured that server and the entry carries an http url. Scoping to
|
|
112
181
|
# the agent's own servers is what keeps one agent's tools from reaching
|
|
113
182
|
# another's.
|
|
183
|
+
#
|
|
184
|
+
# The resolver names a server from the tool's own name: a namespace, a
|
|
185
|
+
# catalog hint, or an allow-list the agent's entry carries. A bare name
|
|
186
|
+
# from a server entry that lists no tools — a checkout runtime enabled as
|
|
187
|
+
# "sandbox:<id>", or {key, name} as the Tools tab saves it — gives it
|
|
188
|
+
# nothing to go on, and the tool the model was just offered would fall
|
|
189
|
+
# through to the toolbox. Prefer the server that listed the tool in
|
|
190
|
+
# tool_definitions: it is one the agent can reach, and it is where the
|
|
191
|
+
# schema the model called came from. Resolver hints are the fallback
|
|
192
|
+
# when no discovered schema names a server.
|
|
193
|
+
#
|
|
194
|
+
# Either way the server's allow-list has the last word. A tool the agent's
|
|
195
|
+
# entry leaves out goes to the toolbox, where the call fails, rather than
|
|
196
|
+
# to a server the user switched it off on — whether the catalog hints it
|
|
197
|
+
# there or the server lists it.
|
|
114
198
|
def endpoint_for(tool_name)
|
|
115
|
-
|
|
199
|
+
name = tool_name.to_s.strip
|
|
200
|
+
|
|
201
|
+
[ listed_by[name], resolver.server_key_for(name) ].each do |key|
|
|
202
|
+
entry = reachable_entry(key)
|
|
203
|
+
return entry if entry && allowed?(key, name)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Whether the agent's entry for +key+ lets this tool through: it names no
|
|
210
|
+
# allow-list, or its allow-list holds the tool, under its bare name or as
|
|
211
|
+
# called (+mcp__<server>__<tool>+).
|
|
212
|
+
def allowed?(key, tool_name)
|
|
213
|
+
allowed = resolver.allowed_tools_for(key)
|
|
214
|
+
return true if allowed.nil?
|
|
215
|
+
|
|
216
|
+
allowed.include?(tool_name) || allowed.include?(ActiveAgent::Telemetry::ToolOrigin.classify(tool_name)[:tool])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The entry for +key+ when the agent enabled that server and it has an
|
|
220
|
+
# http url to call; nil otherwise.
|
|
221
|
+
def reachable_entry(key)
|
|
116
222
|
return nil if key.blank?
|
|
117
|
-
return nil unless
|
|
223
|
+
return nil unless enabled?(key)
|
|
118
224
|
|
|
119
|
-
entry =
|
|
225
|
+
entry = catalog_entry(key)
|
|
120
226
|
return nil unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS)
|
|
121
227
|
return nil if entry[:url].blank?
|
|
122
228
|
|
|
123
229
|
entry
|
|
124
230
|
end
|
|
125
231
|
|
|
232
|
+
# A catalog server, or the app runtime of a checkout sandbox the agent's
|
|
233
|
+
# owner started (keys "sandbox:<session_id>"). The sandbox lookup is scoped
|
|
234
|
+
# to the agent's owner, so naming another tenant's session resolves to
|
|
235
|
+
# nothing.
|
|
236
|
+
def catalog_entry(key)
|
|
237
|
+
return MCPCatalog.find(key) unless SandboxSession.runtime_server_key?(key)
|
|
238
|
+
|
|
239
|
+
SandboxSession.runtime_server_entry(key, owner: agent.try(:owner))
|
|
240
|
+
end
|
|
241
|
+
|
|
126
242
|
# One client per server for the life of this dispatcher, so a run's tool
|
|
127
243
|
# calls share the MCP session the first call opens.
|
|
128
244
|
def client_for(entry)
|
|
129
|
-
@clients[entry[:key]] ||= MCPClient.new(
|
|
245
|
+
@clients[entry[:key]] ||= MCPClient.new(
|
|
246
|
+
url: absolute_url(entry[:url]),
|
|
247
|
+
label: entry[:name] || entry[:key],
|
|
248
|
+
headers: entry[:headers] || {}
|
|
249
|
+
)
|
|
130
250
|
end
|
|
131
251
|
|
|
132
252
|
# A host registers its own servers with a path ("/mcp/diagnostic"), since it
|
|
@@ -29,6 +29,18 @@ module ActionAgent
|
|
|
29
29
|
hourly_cost: tier.hourly_cost.to_f,
|
|
30
30
|
created_at: Time.current
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
# A checkout sandbox: record what would be cloned (never the token) and
|
|
34
|
+
# which credentials would be passed in, and
|
|
35
|
+
# report the runtime's MCP endpoint the way a real backend does. Nothing
|
|
36
|
+
# answers there — this backend runs nothing.
|
|
37
|
+
if (checkout = session.try(:checkout_spec))
|
|
38
|
+
@sandboxes[name][:checkout] = checkout.except(:token)
|
|
39
|
+
# Which variables would be set, never their values.
|
|
40
|
+
@sandboxes[name][:environment_keys] = session.runtime_environment.keys
|
|
41
|
+
@sandboxes[name][:mcp_url] = "http://127.0.0.1:8080/activeagents/mcp"
|
|
42
|
+
end
|
|
43
|
+
|
|
32
44
|
@sandboxes[name]
|
|
33
45
|
end
|
|
34
46
|
|
|
@@ -48,5 +60,32 @@ module ActionAgent
|
|
|
48
60
|
def cleanup_expired
|
|
49
61
|
0
|
|
50
62
|
end
|
|
63
|
+
|
|
64
|
+
# A Claude Code session that runs nothing, reported the way a real one
|
|
65
|
+
# streams it (stream-json: init, the assistant's text, the result), so
|
|
66
|
+
# the dashboard's session flow can be exercised end to end without the
|
|
67
|
+
# CLI or a checkout.
|
|
68
|
+
def run_code_session(_sandbox_session, _code_session, &on_event)
|
|
69
|
+
note = "The mock sandbox backend runs nothing: no Claude Code session ran and nothing in the checkout changed."
|
|
70
|
+
|
|
71
|
+
[
|
|
72
|
+
{ "type" => "system", "subtype" => "init", "model" => "mock" },
|
|
73
|
+
{
|
|
74
|
+
"type" => "assistant",
|
|
75
|
+
"message" => { "role" => "assistant", "content" => [ { "type" => "text", "text" => note } ] }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"type" => "result", "subtype" => "success", "is_error" => false, "result" => note,
|
|
79
|
+
"num_turns" => 1, "duration_ms" => 0, "total_cost_usd" => 0
|
|
80
|
+
}
|
|
81
|
+
].each { |event| on_event&.call(event) }
|
|
82
|
+
|
|
83
|
+
{ exit_status: 0, diff: "", stderr_tail: "" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Nothing runs, so there is nothing to stop.
|
|
87
|
+
def cancel_code_session(_sandbox_session, _code_session)
|
|
88
|
+
true
|
|
89
|
+
end
|
|
51
90
|
end
|
|
52
91
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module ActionAgent
|
|
6
|
+
# Checks that an Ollama (or any OpenAI-compatible) endpoint is reachable and
|
|
7
|
+
# lists the models it serves. Used by Settings -> Provider API Keys ("Test
|
|
8
|
+
# connection") and by the agent builder's live model catalog.
|
|
9
|
+
#
|
|
10
|
+
# The host is the OpenAI-compatible base URL (".../v1"); the probe calls
|
|
11
|
+
# GET {host}/models. An optional API key is sent as a Bearer token for
|
|
12
|
+
# remote servers behind an authenticating proxy or Ollama Cloud.
|
|
13
|
+
class OllamaHostProbe
|
|
14
|
+
TIMEOUT_SECONDS = 4
|
|
15
|
+
|
|
16
|
+
Result = Struct.new(:ok, :host, :models, :latency_ms, :error, keyword_init: true) do
|
|
17
|
+
def to_h
|
|
18
|
+
{ ok: ok, host: host, models: models, latency_ms: latency_ms, error: error }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.call(host:, api_key: nil)
|
|
23
|
+
new(host: host, api_key: api_key).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(host:, api_key: nil)
|
|
27
|
+
@host = ProviderKey.normalize_host(host)
|
|
28
|
+
@api_key = api_key.presence
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
return failure("No host configured") if @host.blank?
|
|
33
|
+
|
|
34
|
+
uri = URI.join("#{@host.chomp('/')}/", "models")
|
|
35
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
response = Net::HTTP.start(
|
|
37
|
+
uri.host, uri.port,
|
|
38
|
+
use_ssl: uri.scheme == "https",
|
|
39
|
+
open_timeout: TIMEOUT_SECONDS,
|
|
40
|
+
read_timeout: TIMEOUT_SECONDS
|
|
41
|
+
) { |http| http.get(uri.request_uri, headers) }
|
|
42
|
+
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
43
|
+
|
|
44
|
+
status = response.code.to_i
|
|
45
|
+
unless status == 200
|
|
46
|
+
hint = " — check the API key" if [ 401, 403 ].include?(status)
|
|
47
|
+
return failure("#{uri} responded #{status}#{hint}", latency_ms)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
data = JSON.parse(response.body)
|
|
51
|
+
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)
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
failure("#{@host} did not return JSON — is this the OpenAI-compatible /v1 endpoint?")
|
|
55
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
56
|
+
failure("#{@host} is not a valid URL")
|
|
57
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError => e
|
|
58
|
+
failure("Could not connect to #{@host} (#{e.class.name.demodulize}) — is the server running and reachable from this machine?")
|
|
59
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
60
|
+
failure("Timed out after #{TIMEOUT_SECONDS}s connecting to #{@host}")
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
failure("#{e.class}: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def headers
|
|
68
|
+
@api_key ? { "Authorization" => "Bearer #{@api_key}" } : {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def failure(message, latency_ms = nil)
|
|
72
|
+
Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to cap the size of JSON-like data handed to an MCP client, whose
|
|
5
|
+
# context window pays for every character. Each cut leaves a marker saying
|
|
6
|
+
# what was dropped, so a reader never mistakes a truncated value for the
|
|
7
|
+
# whole one.
|
|
8
|
+
module PayloadBounds
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns a copy of +value+ with every string cut to +max_string+
|
|
12
|
+
# characters and every array to +max_items+ entries, at any depth. A cut
|
|
13
|
+
# string ends in `…[truncated: N more characters]`; a cut array ends in
|
|
14
|
+
# the string `[truncated: N more items]`. Other values come back as they
|
|
15
|
+
# are.
|
|
16
|
+
#
|
|
17
|
+
# bound({ "output" => "x" * 5 }, max_string: 3)
|
|
18
|
+
# # => { "output" => "xxx…[truncated: 2 more characters]" }
|
|
19
|
+
def bound(value, max_string: 2_000, max_items: 50)
|
|
20
|
+
case value
|
|
21
|
+
when String then truncate_string(value, max_string)
|
|
22
|
+
when Hash then value.to_h { |key, item| [ key, bound(item, max_string: max_string, max_items: max_items) ] }
|
|
23
|
+
when Array
|
|
24
|
+
kept = value.first(max_items).map { |item| bound(item, max_string: max_string, max_items: max_items) }
|
|
25
|
+
value.size > max_items ? kept << "[truncated: #{value.size - max_items} more items]" : kept
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def truncate_string(value, max)
|
|
31
|
+
return value if value.length <= max
|
|
32
|
+
|
|
33
|
+
"#{value[0, max]}…[truncated: #{value.length - max} more characters]"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|