actionagent 1.8.1 → 1.9.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 +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +152 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +96 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# GitHub App installations (Settings -> Integrations): installing the App
|
|
6
|
+
# and linking the installation, choosing the repositories checkout
|
|
7
|
+
# sandboxes may use on it, and unlinking it. Unlinking leaves the App
|
|
8
|
+
# installed on GitHub.
|
|
9
|
+
#
|
|
10
|
+
# #install and #callback are browser navigations rather than fetches, so
|
|
11
|
+
# they answer with redirects back into the dashboard's Settings view,
|
|
12
|
+
# reporting the outcome as ?github_app=<outcome>.
|
|
13
|
+
class GithubInstallationsController < BaseController
|
|
14
|
+
STATE_SESSION_KEY = :action_agent_github_app_state
|
|
15
|
+
|
|
16
|
+
before_action :require_owner!
|
|
17
|
+
before_action :require_github_app!, except: [ :index, :destroy ]
|
|
18
|
+
before_action :set_installation, only: [ :repositories, :update, :destroy ]
|
|
19
|
+
# The repository listing too: an installation can reach repositories a
|
|
20
|
+
# member cannot see on GitHub, and the listing exists only to choose
|
|
21
|
+
# them.
|
|
22
|
+
before_action :authorize_github!, only: [ :install, :callback, :repositories, :update, :destroy ]
|
|
23
|
+
|
|
24
|
+
# Later handlers win, so the subclasses are registered last.
|
|
25
|
+
rescue_from GithubClient::Error, with: :github_unavailable
|
|
26
|
+
rescue_from GithubClient::Unauthorized, with: :github_app_rejected
|
|
27
|
+
rescue_from GithubClient::InstallationUnavailable, with: :installation_unavailable
|
|
28
|
+
|
|
29
|
+
# GET /api/github_installations
|
|
30
|
+
def index
|
|
31
|
+
render json: {
|
|
32
|
+
configured: ActionAgent.github_app_configured?,
|
|
33
|
+
installations: owned(GithubInstallation).order(:id).map(&:as_summary)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /api/github_installations/:id/repositories — what the
|
|
38
|
+
# installation reaches, each marked with whether the owner selected it.
|
|
39
|
+
def repositories
|
|
40
|
+
selected = @installation.repository_names.map(&:downcase).to_set
|
|
41
|
+
|
|
42
|
+
render json: {
|
|
43
|
+
repositories: @installation.listing_client.installation_repositories.map do |repo|
|
|
44
|
+
repo.merge("selected" => selected.include?(repo["full_name"].downcase))
|
|
45
|
+
end
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# PATCH /api/github_installations/:id { repositories: ["owner/name", ...] }
|
|
50
|
+
#
|
|
51
|
+
# Only names GitHub lists for the installation are kept: the selection
|
|
52
|
+
# is what a checkout sandbox trusts, so it is never taken from the
|
|
53
|
+
# client.
|
|
54
|
+
def update
|
|
55
|
+
# Not params.require: an empty list (clear the selection) is valid.
|
|
56
|
+
requested = params[:repositories]
|
|
57
|
+
unless requested.is_a?(Array) && requested.all? { |name| name.is_a?(String) }
|
|
58
|
+
return render json: { error: "repositories must be a list of owner/name strings" }, status: :bad_request
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
available = @installation.listing_client.installation_repositories.index_by { |repo| repo["full_name"].downcase }
|
|
62
|
+
unknown = requested.reject { |name| available.key?(name.downcase) }
|
|
63
|
+
if unknown.any?
|
|
64
|
+
return render json: { error: "Not reachable through this GitHub App installation: #{unknown.join(', ')}" },
|
|
65
|
+
status: :unprocessable_entity
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@installation.update!(repositories: requested.map { |name| available.fetch(name.downcase) }.uniq { |repo| repo["id"] })
|
|
69
|
+
render json: { installation: @installation.as_summary }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# DELETE /api/github_installations/:id
|
|
73
|
+
def destroy
|
|
74
|
+
@installation.destroy!
|
|
75
|
+
head :no_content
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# GET /api/github_installations/install — sends the admin to GitHub to
|
|
79
|
+
# install the App on the repositories they choose.
|
|
80
|
+
def install
|
|
81
|
+
redirect_to GithubClient.installation_url(state: issue_state), allow_other_host: true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# GET /api/github_installations/callback?installation_id=&setup_action=&code=&state=
|
|
85
|
+
#
|
|
86
|
+
# Where GitHub returns from an installation, and from the App's user
|
|
87
|
+
# authorization. A code is exchanged only together with a state this
|
|
88
|
+
# session issued for the signed-in user. Two returns go through the
|
|
89
|
+
# App's user authorization with a fresh state first: one with no state
|
|
90
|
+
# (an install started on GitHub), and one from #install that carries no
|
|
91
|
+
# code (GitHub returning from a change to an installation that already
|
|
92
|
+
# existed).
|
|
93
|
+
def callback
|
|
94
|
+
# Single use: read and cleared before anything else can fail.
|
|
95
|
+
issued = session.delete(STATE_SESSION_KEY)
|
|
96
|
+
return redirect_to_settings(github_app: "denied") if params[:error].present?
|
|
97
|
+
# A member asked an organization owner to install the App. GitHub
|
|
98
|
+
# installs nothing until the owner approves, so there is nothing to
|
|
99
|
+
# link yet.
|
|
100
|
+
return redirect_to_settings(github_app: "pending") if params[:setup_action] == "request"
|
|
101
|
+
|
|
102
|
+
if params[:state].blank?
|
|
103
|
+
return start_user_authorization(installation_id_param) if installation_id_param
|
|
104
|
+
|
|
105
|
+
return redirect_to_settings(github_app: "invalid_state")
|
|
106
|
+
end
|
|
107
|
+
return redirect_to_settings(github_app: "invalid_state") unless issued_here?(issued, params[:state])
|
|
108
|
+
|
|
109
|
+
installation_id = issued["installation_id"] || installation_id_param
|
|
110
|
+
return redirect_to_settings(github_app: "missing_installation") unless installation_id
|
|
111
|
+
|
|
112
|
+
unless params[:code].is_a?(String) && params[:code].present?
|
|
113
|
+
# A state that already carries an installation was issued for the
|
|
114
|
+
# user authorization, which has nothing left to retry.
|
|
115
|
+
return redirect_to_settings(github_app: "missing_code") if issued.key?("installation_id")
|
|
116
|
+
|
|
117
|
+
return start_user_authorization(installation_id)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
redirect_to_settings(github_app: link_installation(installation_id, params[:code]))
|
|
121
|
+
rescue GithubClient::Error => e
|
|
122
|
+
Rails.logger.warn("[ActionAgent] GitHub App installation callback failed: #{e.message}")
|
|
123
|
+
redirect_to_settings(github_app: "error")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Links +installation_id+ to the owner when the user the code belongs
|
|
129
|
+
# to administers the account the App is installed on, and answers the
|
|
130
|
+
# outcome. The user token is held in this method only, for the checks,
|
|
131
|
+
# and is never stored or logged.
|
|
132
|
+
def link_installation(installation_id, code)
|
|
133
|
+
grant = GithubClient.exchange_code(
|
|
134
|
+
code: code, redirect_uri: callback_url,
|
|
135
|
+
client_id: ActionAgent.github_app_client_id, client_secret: ActionAgent.github_app_client_secret
|
|
136
|
+
)
|
|
137
|
+
github = GithubClient.new(grant[:access_token])
|
|
138
|
+
github_user = github.user
|
|
139
|
+
|
|
140
|
+
# The installation_id GitHub put in the URL is only a claim: it has
|
|
141
|
+
# to be one this user can reach.
|
|
142
|
+
installation = github.user_installations.find { |candidate| candidate["id"].to_i == installation_id }
|
|
143
|
+
return "not_found" unless installation
|
|
144
|
+
return "not_admin" unless administers?(github, github_user, installation["account"])
|
|
145
|
+
|
|
146
|
+
record = GithubInstallation.find_by(installation_id: installation_id)
|
|
147
|
+
return "taken" if record && !owned(GithubInstallation).exists?(record.id)
|
|
148
|
+
|
|
149
|
+
record ||= owned(GithubInstallation).new(installation_id: installation_id)
|
|
150
|
+
record.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
|
|
151
|
+
record.assign_from_github(installation)
|
|
152
|
+
record.save!
|
|
153
|
+
"linked"
|
|
154
|
+
# Another owner linked the same installation between the lookup and
|
|
155
|
+
# the save.
|
|
156
|
+
rescue ActiveRecord::RecordNotUnique
|
|
157
|
+
"taken"
|
|
158
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
159
|
+
return "taken" if e.record.errors.of_kind?(:installation_id, :taken)
|
|
160
|
+
|
|
161
|
+
Rails.logger.warn("[ActionAgent] GitHub App installation #{installation_id} was not linked: #{e.record.errors.full_messages.to_sentence}")
|
|
162
|
+
"error"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Whether +github_user+ administers +account+: is that user account, or
|
|
166
|
+
# an active admin of that organization.
|
|
167
|
+
def administers?(github, github_user, account)
|
|
168
|
+
account = {} unless account.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
case account["type"]
|
|
171
|
+
when "User"
|
|
172
|
+
github_user["id"].present? && account["id"].to_i == github_user["id"].to_i
|
|
173
|
+
when "Organization"
|
|
174
|
+
membership = github.organization_membership(account["login"].to_s)
|
|
175
|
+
membership.is_a?(Hash) && membership["state"] == "active" && membership["role"] == "admin"
|
|
176
|
+
else
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A fresh single-use state bound to this session, the signed-in user and
|
|
182
|
+
# the owner. +installation_id+ is carried for the user authorization of
|
|
183
|
+
# an install started on GitHub, which returns without one.
|
|
184
|
+
def issue_state(installation_id: nil)
|
|
185
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
186
|
+
session[STATE_SESSION_KEY] = {
|
|
187
|
+
"state" => state,
|
|
188
|
+
"user_id" => current_user_id,
|
|
189
|
+
"owner_id" => current_owner_id,
|
|
190
|
+
"installation_id" => installation_id
|
|
191
|
+
}.compact
|
|
192
|
+
state
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def issued_here?(issued, given)
|
|
196
|
+
issued.is_a?(Hash) && issued["state"].is_a?(String) && given.is_a?(String) &&
|
|
197
|
+
ActiveSupport::SecurityUtils.secure_compare(issued["state"], given) &&
|
|
198
|
+
issued["user_id"] == current_user_id && issued["owner_id"] == current_owner_id
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def start_user_authorization(installation_id)
|
|
202
|
+
url = GithubClient.authorize_url(
|
|
203
|
+
redirect_uri: callback_url, state: issue_state(installation_id: installation_id),
|
|
204
|
+
client_id: ActionAgent.github_app_client_id, scope: nil
|
|
205
|
+
)
|
|
206
|
+
redirect_to url, allow_other_host: true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def installation_id_param
|
|
210
|
+
value = params[:installation_id]
|
|
211
|
+
value.to_i if value.is_a?(String) && value.match?(/\A[1-9]\d{0,18}\z/)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def current_user_id
|
|
215
|
+
current_user.respond_to?(:id) ? current_user.id.to_s : nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def current_owner_id
|
|
219
|
+
current_owner.respond_to?(:id) ? current_owner.id.to_s : nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def set_installation
|
|
223
|
+
@installation = owned(GithubInstallation).find(params[:id])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Asks about the installation acted on, or about an unsaved one when
|
|
227
|
+
# #install or #callback may create it.
|
|
228
|
+
def authorize_github!
|
|
229
|
+
authorize_action!(:manage_github, @installation || owned(GithubInstallation).new)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# #install and #callback return a refusal to Settings like their other
|
|
233
|
+
# outcomes. The callback's state is discarded with it.
|
|
234
|
+
def permission_denied(action)
|
|
235
|
+
return super unless navigation?
|
|
236
|
+
|
|
237
|
+
session.delete(STATE_SESSION_KEY)
|
|
238
|
+
redirect_to_settings(github_app: "forbidden")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def require_github_app!
|
|
242
|
+
return if ActionAgent.github_app_configured?
|
|
243
|
+
return redirect_to_settings(github_app: "not_configured") if navigation?
|
|
244
|
+
|
|
245
|
+
render json: { error: "No GitHub App is configured on this dashboard", code: "github_app_not_configured" },
|
|
246
|
+
status: :not_found
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def navigation?
|
|
250
|
+
action_name.in?(%w[install callback])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def callback_url
|
|
254
|
+
"#{request.base_url}#{request.script_name}/api/github_installations/callback"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def redirect_to_settings(**query)
|
|
258
|
+
redirect_to "#{request.script_name}/settings?#{{ tab: 'integrations' }.merge(query).to_query}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def github_unavailable(exception)
|
|
262
|
+
render json: { error: exception.message }, status: :bad_gateway
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def github_app_rejected
|
|
266
|
+
render json: { error: "GitHub rejected the GitHub App's credentials. Check github_app_id and github_app_private_key." },
|
|
267
|
+
status: :bad_gateway
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def installation_unavailable(exception)
|
|
271
|
+
state = exception.reason == :suspended ? "is suspended" : "was removed"
|
|
272
|
+
render json: {
|
|
273
|
+
error: "This GitHub App installation #{state} on GitHub. Reinstall the GitHub App, or unlink it.",
|
|
274
|
+
reinstall_required: true,
|
|
275
|
+
installation: @installation&.reload&.as_summary
|
|
276
|
+
}, status: :unprocessable_entity
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The requests for input paused runs are waiting on, and the answers that
|
|
6
|
+
# resume them.
|
|
7
|
+
#
|
|
8
|
+
# Every request is read through the runs the caller can see
|
|
9
|
+
# (owner_input_requests), so one outside them answers 404. Answering or
|
|
10
|
+
# declining asks InputRequest#answerable_by? (403 when denied), and the
|
|
11
|
+
# request must still be pending and unexpired (409). Neither is allowed
|
|
12
|
+
# while agent execution is disabled (403).
|
|
13
|
+
class InputRequestsController < BaseController
|
|
14
|
+
LIST_LIMIT = 100
|
|
15
|
+
|
|
16
|
+
before_action :require_owner!
|
|
17
|
+
# Settling a pause resumes its run, which calls the model.
|
|
18
|
+
before_action :require_execution_enabled!, only: [ :answer, :decline ]
|
|
19
|
+
before_action :set_input_request, only: [ :answer, :decline ]
|
|
20
|
+
before_action :authorize_answer!, only: [ :answer, :decline ]
|
|
21
|
+
|
|
22
|
+
# GET /api/input_requests
|
|
23
|
+
#
|
|
24
|
+
# Pending requests unless `status` names another (or `all`), filtered by
|
|
25
|
+
# `agent_id` and `run_id`, newest first. Overdue requests are expired
|
|
26
|
+
# first, so none is listed as pending.
|
|
27
|
+
def index
|
|
28
|
+
InputRequest.expire_overdue!(owner_input_requests)
|
|
29
|
+
scope = owner_input_requests.for_listing.includes(:subject).recent
|
|
30
|
+
status = params[:status].presence || "pending"
|
|
31
|
+
unless status == "all"
|
|
32
|
+
return render json: { error: "Unknown status #{status.to_s.truncate(32)}" }, status: :bad_request unless InputRequest.statuses.key?(status)
|
|
33
|
+
|
|
34
|
+
scope = scope.where(status: status)
|
|
35
|
+
end
|
|
36
|
+
scope = scope.where(subject_type: AgentRun.polymorphic_name, subject_id: params[:run_id].to_s) if params[:run_id].present?
|
|
37
|
+
if params[:agent_id].present?
|
|
38
|
+
scope = scope.where(subject_type: AgentRun.polymorphic_name, subject_id: AgentRun.where(agent_id: params[:agent_id].to_s).select(:id))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
render json: { input_requests: scope.limit(LIST_LIMIT).map { |request| InputRequestSerializer.call(request) } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POST /api/input_requests/:id/answer
|
|
45
|
+
#
|
|
46
|
+
# `answer` is the answer. A `confirm` request is approved by `true` or
|
|
47
|
+
# by no answer, and declined by `false`.
|
|
48
|
+
def answer
|
|
49
|
+
settle { @input_request.answer!(params[:answer], user: current_user) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /api/input_requests/:id/decline
|
|
53
|
+
def decline
|
|
54
|
+
settle { @input_request.decline!(user: current_user) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def set_input_request
|
|
60
|
+
@input_request = owner_input_requests.find(params[:id])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def authorize_answer!
|
|
64
|
+
permission_denied(:answer_input_request) unless @input_request.answerable_by?(current_user)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def settle
|
|
68
|
+
yield
|
|
69
|
+
render json: { input_request: InputRequestSerializer.call(@input_request) }
|
|
70
|
+
rescue InputRequest::Conflict => e
|
|
71
|
+
render json: { error: e.message, code: "conflict", status: @input_request.reload.status }, status: :conflict
|
|
72
|
+
rescue InputRequest::InvalidAnswer => e
|
|
73
|
+
render json: { error: e.message, code: "invalid_answer" }, status: :unprocessable_entity
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -116,14 +116,20 @@ module ActionAgent
|
|
|
116
116
|
@owner
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# The key's
|
|
119
|
+
# The key's creator when it records one. Otherwise the owner, unless the
|
|
120
120
|
# owner is an account: a tenant is never a user.
|
|
121
121
|
def current_user
|
|
122
|
-
return
|
|
122
|
+
return key_creator if key_creator
|
|
123
123
|
|
|
124
124
|
ActionAgent.multi_tenant? ? nil : @owner
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
def key_creator
|
|
128
|
+
return @key_creator if defined?(@key_creator)
|
|
129
|
+
|
|
130
|
+
@key_creator = @api_key&.creator
|
|
131
|
+
end
|
|
132
|
+
|
|
127
133
|
# The agents this key can reach. A key belongs to whoever owns it, and
|
|
128
134
|
# a single-user install has no owner, so the key reaches every agent
|
|
129
135
|
# the dashboard holds.
|
|
@@ -133,9 +139,9 @@ module ActionAgent
|
|
|
133
139
|
|
|
134
140
|
# The caller an MCP-invoked run executes on behalf of.
|
|
135
141
|
#
|
|
136
|
-
# The
|
|
137
|
-
#
|
|
138
|
-
# with ActionAgent.agent_actor_resolver, which is handed this
|
|
142
|
+
# The default is the user who created the key when the key records
|
|
143
|
+
# one, else the key's owner. A host issuing keys per end user overrides
|
|
144
|
+
# it with ActionAgent.agent_actor_resolver, which is handed this
|
|
139
145
|
# controller and can read the request however it likes.
|
|
140
146
|
#
|
|
141
147
|
# The agent's own callbacks decide what the actor may do — that is the
|
|
@@ -147,7 +153,7 @@ module ActionAgent
|
|
|
147
153
|
if (resolver = ActionAgent.agent_actor_resolver)
|
|
148
154
|
resolver.arity.zero? ? resolver.call : resolver.call(self)
|
|
149
155
|
else
|
|
150
|
-
|
|
156
|
+
key_creator || @owner
|
|
151
157
|
end
|
|
152
158
|
end
|
|
153
159
|
|
|
@@ -178,11 +184,19 @@ module ActionAgent
|
|
|
178
184
|
"resource returns the agent's live scorecard."
|
|
179
185
|
return text unless ActionAgent.mcp_dashboard_tools?
|
|
180
186
|
|
|
181
|
-
"#{text} The evaluations_, evaluation_runs_ and traces_ tools work on this
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
187
|
+
"#{text} The evaluations_, scenarios_, explorations_, evaluation_runs_ and traces_ tools work on this " \
|
|
188
|
+
"account's evaluations and telemetry: create an evaluation with evaluations_create and add scenarios to it " \
|
|
189
|
+
"with scenarios_merge, which never removes or disables a scenario it was not given (neither tool runs " \
|
|
190
|
+
"anything), or submit the questions you found by exploring a project's app with explorations_submit, for a " \
|
|
191
|
+
"person to review and accept on the dashboard (it returns each candidate's verdict), edit the agent " \
|
|
192
|
+
"in your own checkout, start a run with evaluations_run (pass sandbox_id to run against a checkout " \
|
|
193
|
+
"sandbox), poll evaluation_runs_get for its status, results and fix items, compare runs with " \
|
|
194
|
+
"evaluation_runs_compare, and read a failing result's trace with traces_get (traces_search finds recent " \
|
|
195
|
+
"failures). A run_<slug> call that pauses to ask for input returns its input request ids: " \
|
|
196
|
+
"input_requests_list shows what paused runs are waiting on, and input_requests_answer answers a text or " \
|
|
197
|
+
"choice request. Scenarios kept as a YAML catalog of products and sets (an .activeagents/evals file) are " \
|
|
198
|
+
"imported with catalogs_import, listed with catalogs_list, and one set is run as an evaluation named " \
|
|
199
|
+
"catalog/product/set with catalog_set_run (pass project_id to run against a project's running sandbox)."
|
|
186
200
|
end
|
|
187
201
|
|
|
188
202
|
MESSAGE_INPUT_SCHEMA = {
|
|
@@ -261,7 +275,9 @@ module ActionAgent
|
|
|
261
275
|
run = agent.test_execute(message, action: action, actor: agent_actor)
|
|
262
276
|
ActionAgent.record_usage(@owner, :execution)
|
|
263
277
|
|
|
264
|
-
if run.
|
|
278
|
+
if run.awaiting_input?
|
|
279
|
+
awaiting_input_result(run)
|
|
280
|
+
elsif run.failed?
|
|
265
281
|
# A refusal is not a result. An agent that declined on this
|
|
266
282
|
# caller's behalf answers as a JSON-RPC error, so the client sees
|
|
267
283
|
# "not allowed" rather than an empty, confident answer — the
|
|
@@ -283,6 +299,24 @@ module ActionAgent
|
|
|
283
299
|
end
|
|
284
300
|
end
|
|
285
301
|
|
|
302
|
+
# A run that paused to ask a person: its id, status and pending
|
|
303
|
+
# requests, which the dashboard, or input_requests_answer for a text or
|
|
304
|
+
# choice request, answers.
|
|
305
|
+
def awaiting_input_result(run)
|
|
306
|
+
requests = run.pending_input_requests.to_a
|
|
307
|
+
lines = requests.map { |request| "- input request #{request.id} (#{request.kind}): #{request.prompt}" }
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
content: [ { type: "text", text: "The agent paused to ask for input:\n#{lines.join("\n")}" } ],
|
|
311
|
+
structuredContent: {
|
|
312
|
+
run_id: run.id,
|
|
313
|
+
trace_id: run.trace_id,
|
|
314
|
+
status: run.status,
|
|
315
|
+
input_request_ids: requests.map(&:id)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
286
320
|
# Calls a schema tool directly, as this key's caller. No generation runs,
|
|
287
321
|
# so neither the execution switch nor the execution quota applies: this
|
|
288
322
|
# is a read of the host's records through the host's own scope.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The members of the signed-in owner, for the Organization view's Team
|
|
6
|
+
# Members table. The engine keeps no memberships: the host lists them
|
|
7
|
+
# through ActionAgent.members_resolver, and each member carries only
|
|
8
|
+
# id, name, email and role.
|
|
9
|
+
class MembersController < BaseController
|
|
10
|
+
before_action :require_owner!
|
|
11
|
+
|
|
12
|
+
# GET /api/members — { members: [{ id, name, email, role }] }. With no
|
|
13
|
+
# resolver, or one that raised, the signed-in user alone: as "owner" on
|
|
14
|
+
# a single-tenant install, with no role on a multi-tenant one, where
|
|
15
|
+
# the engine cannot know it.
|
|
16
|
+
def index
|
|
17
|
+
members = ActionAgent.members_for(current_owner) || signed_in_member
|
|
18
|
+
|
|
19
|
+
render json: { members: members }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def signed_in_member
|
|
25
|
+
return [] if current_user.nil?
|
|
26
|
+
|
|
27
|
+
[ {
|
|
28
|
+
id: current_user.try(:id),
|
|
29
|
+
name: current_user.try(:display_name) || current_user.try(:name) || current_user.try(:email_address),
|
|
30
|
+
email: current_user.try(:email_address) || current_user.try(:email),
|
|
31
|
+
role: ActionAgent.multi_tenant? ? nil : "owner"
|
|
32
|
+
} ]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Starts the engine's explorer on a project:
|
|
6
|
+
#
|
|
7
|
+
# POST /api/projects/:project_id/explorations { budget: { minutes:, steps:, cost: } }
|
|
8
|
+
#
|
|
9
|
+
# The host's quota is asked about :exploration first, and a denial
|
|
10
|
+
# answers 402 with nothing started. Otherwise the project's ready
|
|
11
|
+
# sandbox gets a browser (started headless with the project's saved
|
|
12
|
+
# sign-in when none runs, which the quota is asked about as
|
|
13
|
+
# :browser_minutes), the explorer's run and the exploration are created,
|
|
14
|
+
# :exploration usage is recorded once, and ExplorationJob walks the app.
|
|
15
|
+
# An explorer exploration of the project that is still walking answers
|
|
16
|
+
# 409. The budget defaults to Exploration::DEFAULT_BUDGET.
|
|
17
|
+
class ProjectExplorationsController < BaseController
|
|
18
|
+
include ProjectBrowser
|
|
19
|
+
|
|
20
|
+
before_action :require_owner!
|
|
21
|
+
before_action :require_execution_enabled!
|
|
22
|
+
before_action :set_project
|
|
23
|
+
|
|
24
|
+
rescue_from Exploration::InvalidCandidate, with: :invalid_budget
|
|
25
|
+
|
|
26
|
+
def create
|
|
27
|
+
budget = Exploration.budget_from(params[:budget])
|
|
28
|
+
enforce_quota!(:exploration)
|
|
29
|
+
return if performed?
|
|
30
|
+
|
|
31
|
+
unless @project.target_agent
|
|
32
|
+
return render json: { error: "Choose the agent to evaluate first", code: "no_target" }, status: :conflict
|
|
33
|
+
end
|
|
34
|
+
if Exploration.where(project_id: @project.id, source: "explorer", status: %w[pending running]).exists?
|
|
35
|
+
return render json: { error: "An exploration of this project is already running", code: "exploration_running" },
|
|
36
|
+
status: :conflict
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sandbox = ready_project_sandbox!(@project) or return
|
|
40
|
+
sandbox, started = ensure_project_browser!(@project, sandbox)
|
|
41
|
+
return if performed?
|
|
42
|
+
|
|
43
|
+
exploration = create_exploration!(sandbox, budget)
|
|
44
|
+
ActionAgent.record_usage(current_owner, :exploration)
|
|
45
|
+
ExplorationJob.perform_later(exploration.id, started)
|
|
46
|
+
|
|
47
|
+
render json: { exploration: exploration.summary }, status: :created
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def set_project
|
|
53
|
+
@project = owned(Project).find(params[:project_id])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create_exploration!(sandbox, budget)
|
|
57
|
+
agent = @project.explorer_agent!
|
|
58
|
+
Exploration.transaction do
|
|
59
|
+
run = agent.agent_runs.create!(
|
|
60
|
+
input_prompt: "Explore #{@project.repository}, starting at #{@project.start_url}.",
|
|
61
|
+
input_params: AgentRun.params_with_actor({}, current_user).merge(
|
|
62
|
+
AgentRun::SANDBOX_PARAM => sandbox.runtime_server_key, AgentRun::BROWSER_PARAM => sandbox.browser_server_key
|
|
63
|
+
),
|
|
64
|
+
trace_id: SecureRandom.uuid,
|
|
65
|
+
status: :pending
|
|
66
|
+
)
|
|
67
|
+
exploration = Exploration.build_for(project: @project, source: "explorer", status: "pending", budget: budget)
|
|
68
|
+
exploration.assign_attributes(agent_run: run, sandbox_session: sandbox,
|
|
69
|
+
session_recording: SessionRecording.recording.where(sandbox_session_id: sandbox.id).order(:id).last)
|
|
70
|
+
exploration.save!
|
|
71
|
+
exploration
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def invalid_budget(exception)
|
|
76
|
+
render json: { error: exception.message }, status: :unprocessable_entity
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|