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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +156 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +27 -130
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +97 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +507 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +166 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +16 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +141 -0
  23. data/app/models/action_agent/evaluation_run.rb +23 -1
  24. data/app/models/action_agent/github_connection.rb +75 -0
  25. data/app/models/action_agent/provider_key.rb +142 -10
  26. data/app/models/action_agent/sandbox_session.rb +193 -17
  27. data/app/serializers/action_agent/evaluation_serializer.rb +118 -0
  28. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  29. data/app/services/action_agent/agent_execution_service.rb +4 -2
  30. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  31. data/app/services/action_agent/claude_code_auth.rb +86 -0
  32. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  33. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  34. data/app/services/action_agent/github_client.rb +111 -0
  35. data/app/services/action_agent/local_sandbox_backend.rb +1689 -0
  36. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  37. data/app/services/action_agent/mcp_client.rb +5 -1
  38. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  39. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  40. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  41. data/app/services/action_agent/payload_bounds.rb +36 -0
  42. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  43. data/app/services/action_agent/sandbox_orchestrator.rb +69 -14
  44. data/app/services/action_agent/scenario_evaluation_runner.rb +50 -4
  45. data/app/services/action_agent/secret_scrubber.rb +37 -0
  46. data/app/services/action_agent/tool_discovery.rb +19 -5
  47. data/config/routes.rb +22 -3
  48. data/lib/action_agent/engine.rb +1 -0
  49. data/lib/action_agent/version.rb +1 -1
  50. data/lib/action_agent.rb +147 -3
  51. data/lib/generators/action_agent/install_generator.rb +30 -3
  52. data/lib/generators/action_agent/templates/action_agent.rb.erb +44 -0
  53. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  54. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  55. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  56. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  57. data/lib/tasks/claude_code.rake +16 -0
  58. data/lib/tasks/sandbox.rake +26 -0
  59. 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
- def initialize(url:, label: nil)
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 is
8
- # called there; anything else returns nil, and the caller falls back to the
9
- # engine's own AgentToolbox.
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
- resolver.declared_server_keys.any? do |key|
32
- entry = MCPCatalog.find(key)
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
- resolver.declared_server_keys.flat_map do |key|
70
- entry = MCPCatalog.find(key)
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 = resolver.declared_server_keys.select do |key|
99
- entry = MCPCatalog.find(key)
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
- key = resolver.server_key_for(tool_name)
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 resolver.status_for(key) == EvaluationToolResolver::ENABLED
223
+ return nil unless enabled?(key)
118
224
 
119
- entry = MCPCatalog.find(key)
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(url: absolute_url(entry[:url]), label: entry[:name] || entry[:key])
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