actionagent 1.7.2 → 1.8.1
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 +168 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
- 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 +105 -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 +509 -0
- data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
- data/app/jobs/action_agent/code_session_job.rb +171 -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 +49 -6
- data/app/models/action_agent/agent_run.rb +20 -1
- data/app/models/action_agent/code_session.rb +145 -0
- data/app/models/action_agent/evaluation.rb +64 -0
- data/app/models/action_agent/evaluation_run.rb +166 -54
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/github_connection.rb +75 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/provider_key.rb +148 -10
- data/app/models/action_agent/sandbox_session.rb +194 -17
- data/app/serializers/action_agent/evaluation_serializer.rb +149 -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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +25 -5
- data/app/services/action_agent/evaluation_standing.rb +145 -0
- 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 +1715 -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 +82 -14
- data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
- data/app/services/action_agent/secret_scrubber.rb +37 -0
- data/app/services/action_agent/tool_discovery.rb +19 -5
- data/config/routes.rb +23 -4
- data/lib/action_agent/engine.rb +1 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +153 -3
- data/lib/generators/action_agent/install_generator.rb +35 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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 +28 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The owner's GitHub connection (Settings -> Integrations): the OAuth
|
|
6
|
+
# web flow that creates it, the repositories it makes available, and
|
|
7
|
+
# disconnecting it. The access token is write-only; responses carry the
|
|
8
|
+
# GitHub login and the chosen repositories, never the token.
|
|
9
|
+
#
|
|
10
|
+
# #connect and #callback are browser navigations rather than fetches, so
|
|
11
|
+
# they answer with redirects back into the dashboard's Settings view.
|
|
12
|
+
class GithubConnectionsController < BaseController
|
|
13
|
+
STATE_SESSION_KEY = :action_agent_github_oauth_state
|
|
14
|
+
|
|
15
|
+
before_action :require_owner!
|
|
16
|
+
before_action :require_github_oauth!, only: [ :connect, :callback ]
|
|
17
|
+
before_action :set_connection, only: [ :repositories, :update, :destroy ]
|
|
18
|
+
|
|
19
|
+
# Later handlers win, so the subclass is registered last.
|
|
20
|
+
rescue_from GithubClient::Error, with: :github_unavailable
|
|
21
|
+
rescue_from GithubClient::Unauthorized, with: :github_unauthorized
|
|
22
|
+
|
|
23
|
+
# GET /api/github_connection
|
|
24
|
+
def show
|
|
25
|
+
connection = owned(GithubConnection).first
|
|
26
|
+
|
|
27
|
+
render json: {
|
|
28
|
+
configured: ActionAgent.github_oauth_configured?,
|
|
29
|
+
connected: connection.present?,
|
|
30
|
+
connection: connection&.as_summary
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /api/github_connection/repositories — what the token reaches,
|
|
35
|
+
# each marked with whether the workspace has it selected.
|
|
36
|
+
def repositories
|
|
37
|
+
selected = @connection.repository_names.map(&:downcase).to_set
|
|
38
|
+
|
|
39
|
+
render json: {
|
|
40
|
+
repositories: @connection.client.repositories.map do |repo|
|
|
41
|
+
repo.merge("selected" => selected.include?(repo["full_name"].downcase))
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# PATCH /api/github_connection { repositories: ["owner/name", ...] }
|
|
47
|
+
#
|
|
48
|
+
# Only names GitHub lists for this token are kept: the selection is
|
|
49
|
+
# what a checkout sandbox trusts, so it is never taken from the client.
|
|
50
|
+
def update
|
|
51
|
+
# Not params.require: an empty list (clear the selection) is valid.
|
|
52
|
+
requested = params[:repositories]
|
|
53
|
+
unless requested.is_a?(Array) && requested.all? { |name| name.is_a?(String) }
|
|
54
|
+
return render json: { error: "repositories must be a list of owner/name strings" }, status: :bad_request
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
available = @connection.client.repositories.index_by { |repo| repo["full_name"].downcase }
|
|
58
|
+
unknown = requested.reject { |name| available.key?(name.downcase) }
|
|
59
|
+
if unknown.any?
|
|
60
|
+
return render json: { error: "Not reachable with this GitHub connection: #{unknown.join(', ')}" },
|
|
61
|
+
status: :unprocessable_entity
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@connection.update!(repositories: requested.map { |name| available.fetch(name.downcase) }.uniq { |repo| repo["id"] })
|
|
65
|
+
render json: { connected: true, connection: @connection.as_summary }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# DELETE /api/github_connection
|
|
69
|
+
def destroy
|
|
70
|
+
@connection.destroy!
|
|
71
|
+
head :no_content
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# GET /api/github_connection/connect — starts the OAuth web flow.
|
|
75
|
+
def connect
|
|
76
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
77
|
+
session[STATE_SESSION_KEY] = state
|
|
78
|
+
|
|
79
|
+
redirect_to GithubClient.authorize_url(redirect_uri: callback_url, state: state), allow_other_host: true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# GET /api/github_connection/callback?code=...&state=...
|
|
83
|
+
def callback
|
|
84
|
+
# Single use: read and cleared before anything else can fail.
|
|
85
|
+
expected = session.delete(STATE_SESSION_KEY)
|
|
86
|
+
return redirect_to_settings(github: "denied") if params[:error].present?
|
|
87
|
+
|
|
88
|
+
unless expected.present? && params[:state].is_a?(String) &&
|
|
89
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, params[:state])
|
|
90
|
+
return redirect_to_settings(github: "invalid_state")
|
|
91
|
+
end
|
|
92
|
+
return redirect_to_settings(github: "missing_code") unless params[:code].is_a?(String) && params[:code].present?
|
|
93
|
+
|
|
94
|
+
grant = GithubClient.exchange_code(code: params[:code], redirect_uri: callback_url)
|
|
95
|
+
user = GithubClient.new(grant[:access_token]).user
|
|
96
|
+
|
|
97
|
+
# Built through the owner scope, like a provider key, so the new
|
|
98
|
+
# record carries whichever owner column this install uses.
|
|
99
|
+
connection = owned(GithubConnection).first || owned(GithubConnection).new
|
|
100
|
+
# A different GitHub account starts with nothing selected: the old
|
|
101
|
+
# selection was checked against the other account's access.
|
|
102
|
+
connection.repositories = [] if connection.persisted? && connection.github_user_id != user["id"]
|
|
103
|
+
connection.update!(
|
|
104
|
+
access_token: grant[:access_token],
|
|
105
|
+
scopes: grant[:scope],
|
|
106
|
+
github_user_id: user["id"],
|
|
107
|
+
login: user["login"],
|
|
108
|
+
avatar_url: user["avatar_url"]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
redirect_to_settings(github: "connected")
|
|
112
|
+
rescue GithubClient::Error => e
|
|
113
|
+
Rails.logger.warn("[ActionAgent] GitHub OAuth callback failed: #{e.message}")
|
|
114
|
+
redirect_to_settings(github: "error")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def set_connection
|
|
120
|
+
@connection = owned(GithubConnection).first!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def require_github_oauth!
|
|
124
|
+
return if ActionAgent.github_oauth_configured?
|
|
125
|
+
|
|
126
|
+
redirect_to_settings(github: "not_configured")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def callback_url
|
|
130
|
+
"#{request.base_url}#{request.script_name}/api/github_connection/callback"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def redirect_to_settings(**query)
|
|
134
|
+
redirect_to "#{request.script_name}/settings?#{{ tab: 'integrations' }.merge(query).to_query}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def github_unauthorized
|
|
138
|
+
render json: { error: "GitHub rejected the stored token. Reconnect GitHub.", reconnect_required: true },
|
|
139
|
+
status: :unprocessable_entity
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def github_unavailable(exception)
|
|
143
|
+
render json: { error: exception.message }, status: :bad_gateway
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -11,7 +11,9 @@ module ActionAgent
|
|
|
11
11
|
# schema tools (ActiveAgent::SchemaTools, the classes the dashboard
|
|
12
12
|
# discovers) is callable directly — find_<records>, count_<records>,
|
|
13
13
|
# get_<record> — as this key's caller, so a client reads the host's
|
|
14
|
-
# records under the same scope an agent run would (#439).
|
|
14
|
+
# records under the same scope an agent run would (#439). The
|
|
15
|
+
# dashboard's own evaluation and telemetry tools (MCPDashboardTools)
|
|
16
|
+
# are offered beside them.
|
|
15
17
|
# - resources/list & resources/read: each agent is an agent://<slug>
|
|
16
18
|
# resource whose content is its live scorecard (config + stats + memory
|
|
17
19
|
# summary from the solid_agent datasets).
|
|
@@ -30,6 +32,8 @@ module ActionAgent
|
|
|
30
32
|
skip_forgery_protection
|
|
31
33
|
before_action :authenticate_api_key!, except: [ :unsupported ]
|
|
32
34
|
|
|
35
|
+
include MCPDashboardTools
|
|
36
|
+
|
|
33
37
|
PROTOCOL_VERSION = "2025-03-26"
|
|
34
38
|
JSONRPC_METHOD_NOT_FOUND = -32601
|
|
35
39
|
JSONRPC_INVALID_PARAMS = -32602
|
|
@@ -105,6 +109,21 @@ module ActionAgent
|
|
|
105
109
|
@owner = api_key.owner
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
# The key's owner stands in for the signed-in owner, so the dashboard's
|
|
113
|
+
# own scopes (owned, owner_agents, owned_traces, RunSandbox) read what
|
|
114
|
+
# the JSON API would read for that owner.
|
|
115
|
+
def current_owner
|
|
116
|
+
@owner
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The key's user when it records one. Otherwise the owner, unless the
|
|
120
|
+
# owner is an account: a tenant is never a user.
|
|
121
|
+
def current_user
|
|
122
|
+
return @api_key.user if @api_key.respond_to?(:user) && @api_key.user
|
|
123
|
+
|
|
124
|
+
ActionAgent.multi_tenant? ? nil : @owner
|
|
125
|
+
end
|
|
126
|
+
|
|
108
127
|
# The agents this key can reach. A key belongs to whoever owns it, and
|
|
109
128
|
# a single-user install has no owner, so the key reaches every agent
|
|
110
129
|
# the dashboard holds.
|
|
@@ -149,12 +168,23 @@ module ActionAgent
|
|
|
149
168
|
protocolVersion: PROTOCOL_VERSION,
|
|
150
169
|
capabilities: { tools: {}, resources: {} },
|
|
151
170
|
serverInfo: { name: "activeagents", version: "1.0" },
|
|
152
|
-
instructions:
|
|
153
|
-
"application's records directly, as the caller this key authenticates. Each agent://<slug> " \
|
|
154
|
-
"resource returns the agent's live scorecard."
|
|
171
|
+
instructions: initialize_instructions
|
|
155
172
|
}
|
|
156
173
|
end
|
|
157
174
|
|
|
175
|
+
def initialize_instructions
|
|
176
|
+
text = "Each run_<slug> tool runs one of this account's agents; each find_, count_ and get_ tool reads the " \
|
|
177
|
+
"host application's records directly, as the caller this key authenticates. Each agent://<slug> " \
|
|
178
|
+
"resource returns the agent's live scorecard."
|
|
179
|
+
return text unless ActionAgent.mcp_dashboard_tools?
|
|
180
|
+
|
|
181
|
+
"#{text} The evaluations_, evaluation_runs_ and traces_ tools work on this account's evaluations and " \
|
|
182
|
+
"telemetry: edit the agent in your own checkout, start a run with evaluations_run (pass sandbox_id to run " \
|
|
183
|
+
"against a checkout sandbox), poll evaluation_runs_get for its status, results and fix items, compare runs " \
|
|
184
|
+
"with evaluation_runs_compare, and read a failing result's trace with traces_get (traces_search finds " \
|
|
185
|
+
"recent failures)."
|
|
186
|
+
end
|
|
187
|
+
|
|
158
188
|
MESSAGE_INPUT_SCHEMA = {
|
|
159
189
|
type: "object",
|
|
160
190
|
properties: {
|
|
@@ -182,7 +212,7 @@ module ActionAgent
|
|
|
182
212
|
agent_tools
|
|
183
213
|
end
|
|
184
214
|
|
|
185
|
-
{ tools: tools + schema_tools_list }
|
|
215
|
+
{ tools: dashboard_tools_list + tools + schema_tools_list }
|
|
186
216
|
end
|
|
187
217
|
|
|
188
218
|
# The host's schema tools, offered as the same tool definitions an
|
|
@@ -206,6 +236,9 @@ module ActionAgent
|
|
|
206
236
|
|
|
207
237
|
def tools_call
|
|
208
238
|
name = params.dig(:params, :name).to_s
|
|
239
|
+
# The three families' names are disjoint (see MCPDashboardTools), so
|
|
240
|
+
# this order never decides between two tools.
|
|
241
|
+
return dashboard_tool_call(name) if dashboard_tool?(name)
|
|
209
242
|
return schema_tool_call(name) if ActionAgent.mcp_schema_tools? && ActionAgent.schema_tool_class_for(name)
|
|
210
243
|
|
|
211
244
|
slug, action = name.delete_prefix("run_").split("__", 2)
|
|
@@ -9,6 +9,8 @@ module ActionAgent
|
|
|
9
9
|
# and solid_agent records (ToolDiscovery), servers an agent declares in
|
|
10
10
|
# its configuration, and the default catalog (MCPCatalog). An install
|
|
11
11
|
# therefore sees both what it is already using and what it could turn on.
|
|
12
|
+
# The caller's live checkout sandbox runtimes are listed with the catalog,
|
|
13
|
+
# so the one started from Settings -> Integrations shows up here too.
|
|
12
14
|
class MCPServersController < BaseController
|
|
13
15
|
before_action :require_owner!
|
|
14
16
|
# Launching provisions a sandbox and runs a server in it, so it answers
|
|
@@ -108,8 +110,15 @@ module ActionAgent
|
|
|
108
110
|
|
|
109
111
|
private
|
|
110
112
|
|
|
113
|
+
# Runtimes are the caller's own: a listing carries no token, but which
|
|
114
|
+
# repositories another owner has running is still theirs to know.
|
|
111
115
|
def discovery
|
|
112
|
-
ToolDiscovery.new(
|
|
116
|
+
ToolDiscovery.new(
|
|
117
|
+
traces: owned_traces,
|
|
118
|
+
agents: owner_agents,
|
|
119
|
+
hours: window_hours,
|
|
120
|
+
runtimes: SandboxSession.runtime_server_listings(owned(SandboxSession))
|
|
121
|
+
)
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
# The catalog names this dashboard's own MCP endpoint as "<mount>/mcp",
|
|
@@ -4,7 +4,9 @@ module ActionAgent
|
|
|
4
4
|
module Api
|
|
5
5
|
# Per-account LLM provider credentials (Settings -> Provider API Keys).
|
|
6
6
|
# API keys are write-only: responses carry a masked hint, never the key.
|
|
7
|
-
# Ollama's credential is a host URL and is echoed back in full
|
|
7
|
+
# Ollama's credential is a host URL and is echoed back in full; its
|
|
8
|
+
# optional API key (remote servers) is masked like the others. Claude
|
|
9
|
+
# Code's connection API key is stored here too, write-only like a key.
|
|
8
10
|
class ProviderKeysController < BaseController
|
|
9
11
|
before_action :require_owner!
|
|
10
12
|
|
|
@@ -20,16 +22,42 @@ module ActionAgent
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# POST /api/provider_keys — upserts the credential for a provider.
|
|
25
|
+
# Host-based providers may also carry an optional api_key; omitting it
|
|
26
|
+
# keeps the stored one, sending an empty string clears it.
|
|
23
27
|
def create
|
|
24
28
|
provider = params.require(:provider)
|
|
25
29
|
credential = params.require(:credential)
|
|
26
30
|
|
|
27
31
|
record = owned(ProviderKey).find_or_initialize_by(provider: provider)
|
|
28
|
-
|
|
32
|
+
attributes = { credential: credential }
|
|
33
|
+
attributes[:api_key] = params[:api_key].presence if params.key?(:api_key) && record.host_based?
|
|
34
|
+
record.update!(**attributes)
|
|
29
35
|
|
|
30
36
|
render json: { provider_key: serialize(provider, record) }, status: :created
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
# POST /api/provider_keys/test — checks that a host-based provider is
|
|
40
|
+
# reachable and lists the models it serves. Tests the submitted host
|
|
41
|
+
# (and api_key) when given, so a URL can be checked before saving;
|
|
42
|
+
# otherwise the stored credential, else the host app's default. Never
|
|
43
|
+
# persists anything.
|
|
44
|
+
def test
|
|
45
|
+
provider = params.require(:provider)
|
|
46
|
+
unless ProviderKey::HOST_PROVIDERS.include?(provider)
|
|
47
|
+
return render json: { error: "#{provider} is not a host-based provider" }, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
stored = owned(ProviderKey).find_by(provider: provider)
|
|
51
|
+
host = params[:credential].presence || stored&.credential || platform_host(provider)
|
|
52
|
+
api_key = params.key?(:api_key) ? params[:api_key].presence : stored&.api_key
|
|
53
|
+
|
|
54
|
+
if host.blank?
|
|
55
|
+
return render json: { ok: false, host: nil, models: [], latency_ms: nil, error: "No host configured" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
render json: OllamaHostProbe.call(host: host, api_key: api_key).to_h
|
|
59
|
+
end
|
|
60
|
+
|
|
33
61
|
# DELETE /api/provider_keys/:provider
|
|
34
62
|
def destroy
|
|
35
63
|
owned(ProviderKey).find_by!(provider: params[:provider]).destroy!
|
|
@@ -38,12 +66,35 @@ module ActionAgent
|
|
|
38
66
|
|
|
39
67
|
private
|
|
40
68
|
|
|
69
|
+
# The host app's default (config/active_agent.yml, e.g. OLLAMA_HOST)
|
|
70
|
+
# that applies when the owner has not configured their own.
|
|
71
|
+
def platform_host(provider)
|
|
72
|
+
return nil unless ProviderKey::HOST_PROVIDERS.include?(provider)
|
|
73
|
+
|
|
74
|
+
config = ActiveAgent.configuration[provider.to_sym]
|
|
75
|
+
config.respond_to?(:[]) ? config[:host].presence : nil
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
41
80
|
def serialize(provider, record)
|
|
81
|
+
host_based = ProviderKey::HOST_PROVIDERS.include?(provider)
|
|
82
|
+
|
|
42
83
|
{
|
|
43
84
|
provider: provider,
|
|
44
|
-
host_based:
|
|
85
|
+
host_based: host_based,
|
|
86
|
+
# "key", "host", or "connection" (Settings -> Integrations rather
|
|
87
|
+
# than Provider API Keys).
|
|
88
|
+
kind: ProviderKey.kind_of_provider(provider),
|
|
45
89
|
configured: record.present?,
|
|
46
90
|
hint: record&.display_hint,
|
|
91
|
+
api_key_configured: record&.api_key? || false,
|
|
92
|
+
api_key_hint: record&.api_key_hint,
|
|
93
|
+
platform_default: host_based && record.nil? ? platform_host(provider) : nil,
|
|
94
|
+
# A Claude Code connection still holding a Claude subscription token
|
|
95
|
+
# from an earlier version: never used, and the UI asks for an API
|
|
96
|
+
# key in its place.
|
|
97
|
+
needs_replacing: record.present? && record.needs_replacing?,
|
|
47
98
|
updated_at: record&.updated_at&.iso8601
|
|
48
99
|
}
|
|
49
100
|
end
|
|
@@ -67,16 +67,20 @@ module ActionAgent
|
|
|
67
67
|
owned(ProviderKey).find_by(provider: provider)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Same probe as Settings -> "Test connection", so the builder's dropdown
|
|
71
|
+
# and the settings page agree on what the host serves (including the
|
|
72
|
+
# optional Bearer key for remote servers).
|
|
70
73
|
def live_ollama_models
|
|
71
74
|
host = ollama_host
|
|
72
75
|
return nil unless host
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
result = OllamaHostProbe.call(host: host, api_key: owner_provider_key("ollama")&.api_key)
|
|
78
|
+
unless result.ok
|
|
79
|
+
Rails.logger.warn("[ProviderModels] ollama lookup failed: #{result.error}")
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[ result.models, "live" ] if result.models.any?
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# Queries the Anthropic Models API with the account's key (newest first,
|
|
@@ -12,6 +12,9 @@ module ActionAgent
|
|
|
12
12
|
# development, so it bypassed that safeguard too.
|
|
13
13
|
|
|
14
14
|
before_action :require_execution_enabled!, only: [ :run, :compare ]
|
|
15
|
+
# A checkout runs the owner's code (setup, server): the same gates as
|
|
16
|
+
# running an agent, like MCPServersController#launch.
|
|
17
|
+
before_action :gate_checkout!, only: [ :create ]
|
|
15
18
|
before_action :enforce_execution_quota!, only: [ :compare ]
|
|
16
19
|
before_action :set_sandbox, only: [ :show, :run, :destroy ]
|
|
17
20
|
|
|
@@ -83,16 +86,34 @@ module ActionAgent
|
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
# GET /api/sandboxes
|
|
86
|
-
# List available sandbox types and sample tasks
|
|
89
|
+
# List available sandbox types and sample tasks, and the caller's own
|
|
90
|
+
# sandboxes (?sandbox_type= narrows them), with what the Settings ->
|
|
91
|
+
# Integrations view needs to offer Claude Code sessions in a checkout.
|
|
87
92
|
def index
|
|
88
93
|
render json: {
|
|
89
94
|
sandbox_types: SandboxSession::SANDBOX_TYPES,
|
|
90
95
|
free_tier_limits: SandboxSession::FREE_TIER_LIMITS,
|
|
91
96
|
templates: free_tier_templates,
|
|
92
|
-
sample_tasks: sample_tasks
|
|
97
|
+
sample_tasks: sample_tasks,
|
|
98
|
+
sandboxes: listed_sandboxes.map(&:summary),
|
|
99
|
+
code_sessions_supported: code_sessions_supported?,
|
|
100
|
+
codex_sessions_supported: codex_sessions_supported?,
|
|
101
|
+
codex_connected: owned(ProviderKey).where(provider: "codex").any? { |key| key.runtime_environment.present? },
|
|
102
|
+
**claude_code_status
|
|
93
103
|
}
|
|
94
104
|
end
|
|
95
105
|
|
|
106
|
+
# Refuses a checkout the owner may not start. Usage is recorded by
|
|
107
|
+
# #create once the sandbox saved: a request refused for its own
|
|
108
|
+
# content (a repository that is not selected, say) runs nothing, so it
|
|
109
|
+
# must not spend a plan run.
|
|
110
|
+
def gate_checkout!
|
|
111
|
+
return unless checkout_requested?
|
|
112
|
+
|
|
113
|
+
require_execution_enabled!
|
|
114
|
+
enforce_execution_quota! unless performed?
|
|
115
|
+
end
|
|
116
|
+
|
|
96
117
|
# POST /api/sandboxes
|
|
97
118
|
# Create a new sandbox session, owned by whoever opened it.
|
|
98
119
|
def create
|
|
@@ -100,11 +121,17 @@ module ActionAgent
|
|
|
100
121
|
# Guarded: the association only exists when the host app configured a
|
|
101
122
|
# user model, and a single-user install configures none.
|
|
102
123
|
@sandbox.user = current_user if @sandbox.respond_to?(:user=)
|
|
124
|
+
# A checkout is validated against the owner's GitHub connection, which
|
|
125
|
+
# an account-owned install finds through the account.
|
|
126
|
+
@sandbox.account_id = current_account.id if current_account && @sandbox.has_attribute?(:account_id)
|
|
103
127
|
@sandbox.agent_template = AgentTemplate.find_by(slug: params[:template_slug]) if params[:template_slug]
|
|
104
128
|
|
|
105
129
|
if @sandbox.save
|
|
106
130
|
@sandbox.provision!
|
|
107
131
|
@sandbox.reload # Reload to get updated status after provisioning
|
|
132
|
+
# Counted once the checkout exists, as MCPServersController#launch
|
|
133
|
+
# counts a launched server.
|
|
134
|
+
record_execution_usage if checkout_requested?
|
|
108
135
|
render json: { sandbox: @sandbox.summary }, status: :created
|
|
109
136
|
else
|
|
110
137
|
render json: { errors: @sandbox.errors.full_messages }, status: :unprocessable_entity
|
|
@@ -162,20 +189,92 @@ module ActionAgent
|
|
|
162
189
|
end
|
|
163
190
|
|
|
164
191
|
# DELETE /api/sandboxes/:session_id
|
|
165
|
-
# End sandbox session
|
|
192
|
+
# End sandbox session. Expiring it enqueues SandboxCleanupJob, which
|
|
193
|
+
# terminates whatever the backend runs for it (a checkout's processes
|
|
194
|
+
# included); one still provisioning is released by
|
|
195
|
+
# SandboxProvisionJob when its backend returns.
|
|
166
196
|
def destroy
|
|
167
197
|
@sandbox.expire!
|
|
168
|
-
render json: { deleted: true }
|
|
198
|
+
render json: { deleted: true, sandbox: @sandbox.summary }
|
|
169
199
|
end
|
|
170
200
|
|
|
171
201
|
private
|
|
172
202
|
|
|
173
203
|
def set_sandbox
|
|
174
|
-
@sandbox = owned(SandboxSession).find_by!(session_id: params[:id])
|
|
204
|
+
@sandbox = account_scoped(owned(SandboxSession)).find_by!(session_id: params[:id])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def checkout_requested?
|
|
208
|
+
params[:sandbox_type].to_s == "app_runtime"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A checkout runs on its account's GitHub token and Claude Code
|
|
212
|
+
# credential, so in a multi-tenant install it is listed, shown and
|
|
213
|
+
# stopped only within the caller's current account, as
|
|
214
|
+
# CodeSessionsController finds it: listing another account's checkout
|
|
215
|
+
# as Ready offered a Claude Code panel that then answered 404. Other
|
|
216
|
+
# sandbox types are the caller's own wherever they were opened.
|
|
217
|
+
def account_scoped(scope)
|
|
218
|
+
return scope unless current_account
|
|
219
|
+
|
|
220
|
+
scope.where.not(sandbox_type: "app_runtime").or(scope.where(account_id: current_account.id))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Whether the configured backend can run Claude Code sessions. A
|
|
224
|
+
# backend that cannot even be loaded (a misspelled class in
|
|
225
|
+
# ActionAgent.sandbox_backends, or a class file requiring an SDK the
|
|
226
|
+
# host doesn't bundle, which raises LoadError) cannot, and must not
|
|
227
|
+
# take the rest of this listing down with it.
|
|
228
|
+
#
|
|
229
|
+
# Nor can one whose Claude Code authentication does not work there
|
|
230
|
+
# (ActionAgent.claude_code_auth = :local_login needs :local).
|
|
231
|
+
def code_sessions_supported?
|
|
232
|
+
orchestrator = SandboxOrchestrator.new
|
|
233
|
+
orchestrator.supports?(:code_session) && ClaudeCodeAuth.backend_refusal(orchestrator).nil?
|
|
234
|
+
rescue StandardError, LoadError => e
|
|
235
|
+
Rails.logger.warn("[ActionAgent] sandbox backend unavailable: #{e.message}")
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def codex_sessions_supported?
|
|
240
|
+
SandboxOrchestrator.new.supports_code_runner?("codex")
|
|
241
|
+
rescue StandardError, LoadError
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Whether the caller's Claude Code can run sessions, by
|
|
246
|
+
# ClaudeCodeAuth's rule: an API key they connected, or this machine's
|
|
247
|
+
# own login. Never a credential.
|
|
248
|
+
#
|
|
249
|
+
# claude_code_auth "api_key" | "local_login"
|
|
250
|
+
# claude_code_connected Boolean
|
|
251
|
+
# claude_code_login { logged_in:, auth_method: } (local_login only)
|
|
252
|
+
#
|
|
253
|
+
# The key is found through its own owner column, as
|
|
254
|
+
# SandboxSession#runtime_environment finds the credential it hands a
|
|
255
|
+
# checkout: a provider key is account-owned before user-owned.
|
|
256
|
+
def claude_code_status
|
|
257
|
+
status = ClaudeCodeAuth.status(owned(ProviderKey))
|
|
258
|
+
{
|
|
259
|
+
claude_code_auth: status[:mode],
|
|
260
|
+
claude_code_connected: status[:connected],
|
|
261
|
+
claude_code_login: status[:login]
|
|
262
|
+
}.compact
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The caller's sandboxes that have not expired, newest first. A failed
|
|
266
|
+
# one stays listed so its error can be read; one past its expiry but
|
|
267
|
+
# not reaped yet stays listed so it can still be stopped.
|
|
268
|
+
def listed_sandboxes
|
|
269
|
+
scope = account_scoped(owned(SandboxSession)).where.not(status: :expired).recent.limit(20)
|
|
270
|
+
type = params[:sandbox_type]
|
|
271
|
+
type.is_a?(String) && type.present? ? scope.by_type(type) : scope
|
|
175
272
|
end
|
|
176
273
|
|
|
274
|
+
# An app_runtime sandbox also names the checkout: one of the owner's
|
|
275
|
+
# selected GitHub repositories and, optionally, a ref.
|
|
177
276
|
def sandbox_params
|
|
178
|
-
params.permit(:sandbox_type)
|
|
277
|
+
params.permit(:sandbox_type, :repository, :repository_ref)
|
|
179
278
|
end
|
|
180
279
|
|
|
181
280
|
def free_tier_templates
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Starting an evaluation run on the caller's behalf, shared by the
|
|
6
|
+
# evaluations API (POST /api/evaluations/:id/run) and the MCP facade's
|
|
7
|
+
# `evaluations_run` tool so both check a run the same way.
|
|
8
|
+
#
|
|
9
|
+
# A scenario suite accepts a selection that narrows the run:
|
|
10
|
+
# - scenario_ids: the scenarios to replay, by id
|
|
11
|
+
# - keys: the scenarios to replay, by key
|
|
12
|
+
# - group: one scenario group
|
|
13
|
+
# - models: the candidate models, as an array or a comma-separated string
|
|
14
|
+
# - sandbox_id: a checkout sandbox of the caller's that every replay reaches (see RunSandbox)
|
|
15
|
+
module EvaluationRunStarting
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
include RunSandbox
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Starts a run of +evaluation+ and returns it. A scenario suite replays
|
|
23
|
+
# through the provider once per scenario and model, so it runs in the
|
|
24
|
+
# background and the run comes back pending. A generation-sampling
|
|
25
|
+
# evaluation scores recorded data and finishes inline.
|
|
26
|
+
#
|
|
27
|
+
# A failure is recorded on the run rather than raised, so the caller
|
|
28
|
+
# always gets a run to report.
|
|
29
|
+
#
|
|
30
|
+
# @return [EvaluationRun]
|
|
31
|
+
def start_evaluation_run(evaluation, selection)
|
|
32
|
+
return evaluation.run_later!(**selection) if evaluation.scenario_suite?
|
|
33
|
+
|
|
34
|
+
evaluation.run!
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Rails.logger.warn(
|
|
37
|
+
"[ActionAgent] evaluation #{evaluation.id} run failed: #{e.class}: #{e.message}"
|
|
38
|
+
)
|
|
39
|
+
# EvaluationRunnerService records the failure on the run before
|
|
40
|
+
# re-raising. A failure before the run record existed is recorded here.
|
|
41
|
+
evaluation.evaluation_runs.recent.first ||
|
|
42
|
+
evaluation.evaluation_runs.create!(status: :failed, error_message: e.message, completed_at: Time.current)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the selection +source+ (params or tool arguments) asks for,
|
|
46
|
+
# without the sandbox, which evaluation_run_sandbox checks separately.
|
|
47
|
+
def evaluation_run_selection(source)
|
|
48
|
+
models = source[:models]
|
|
49
|
+
models = models.to_s.split(",") unless models.is_a?(Array)
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
scenario_ids: Array(source[:scenario_ids]).map(&:to_s).reject(&:blank?),
|
|
53
|
+
keys: Array(source[:keys]).map(&:to_s).reject(&:blank?),
|
|
54
|
+
group: source[:group].to_s.presence,
|
|
55
|
+
models: models.map(&:to_s).map(&:strip).reject(&:blank?)
|
|
56
|
+
}.compact_blank
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the checked sandbox +sandbox_id+ names for a run of
|
|
60
|
+
# +evaluation+, or nil when none was asked for. Raises RunSandbox::Refused.
|
|
61
|
+
#
|
|
62
|
+
# Only a scenario suite's own replay executes the agent: a sampling run
|
|
63
|
+
# scores recorded generations, and a host adapter replays in the host's
|
|
64
|
+
# runtime, where no dashboard dispatcher runs.
|
|
65
|
+
#
|
|
66
|
+
# @return [SandboxSession, nil]
|
|
67
|
+
def evaluation_run_sandbox(evaluation, sandbox_id)
|
|
68
|
+
return nil if sandbox_id.blank?
|
|
69
|
+
|
|
70
|
+
unless evaluation.scenario_suite?
|
|
71
|
+
raise RunSandbox::Refused, "Only a scenario evaluation runs the agent; this one scores recorded generations, " \
|
|
72
|
+
"so it cannot run against a sandbox"
|
|
73
|
+
end
|
|
74
|
+
if scenario_evaluation_adapter?(evaluation)
|
|
75
|
+
raise RunSandbox::Refused, "This install replays scenarios through its own adapter, which cannot reach a " \
|
|
76
|
+
"dashboard sandbox"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
run_sandbox_for(evaluation.agent, sandbox_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a run of +evaluation+ cannot replay its scenarios: its agent
|
|
83
|
+
# is observed (read-only), and no host adapter replays it instead.
|
|
84
|
+
def unexecutable_scenario_run?(evaluation)
|
|
85
|
+
evaluation.agent.observed? && !scenario_evaluation_adapter?(evaluation)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scenario_evaluation_adapter?(evaluation)
|
|
89
|
+
ActionAgent.scenario_evaluation_adapter_resolver&.call(evaluation).respond_to?(:call)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|