actionagent 1.8.1 → 1.9.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.
Files changed (163) 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 +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
  18. data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
  19. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  20. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  21. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  22. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  23. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  24. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  25. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  26. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  27. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  28. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  29. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  30. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  31. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  32. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  33. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  34. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  35. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  36. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  37. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  38. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  39. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  40. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  41. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  42. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  43. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  44. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  45. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  46. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  47. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  48. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  49. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  50. data/app/jobs/action_agent/code_session_job.rb +26 -2
  51. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  52. data/app/jobs/action_agent/exploration_job.rb +80 -0
  53. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  54. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  55. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  56. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  57. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  58. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  59. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  60. data/app/models/action_agent/agent.rb +39 -26
  61. data/app/models/action_agent/agent_run.rb +134 -23
  62. data/app/models/action_agent/agent_template.rb +35 -1
  63. data/app/models/action_agent/api_key.rb +14 -0
  64. data/app/models/action_agent/catalog_scenario.rb +75 -0
  65. data/app/models/action_agent/code_session.rb +16 -4
  66. data/app/models/action_agent/draft_pull_request.rb +131 -0
  67. data/app/models/action_agent/evaluation.rb +84 -0
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/interaction_feedback.rb +24 -0
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +200 -3
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +297 -14
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  85. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  86. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  87. data/app/services/action_agent/agent_execution_service.rb +450 -72
  88. data/app/services/action_agent/agent_sync.rb +5 -2
  89. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  90. data/app/services/action_agent/agent_toolbox.rb +215 -9
  91. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  92. data/app/services/action_agent/browser_sidecar.rb +213 -0
  93. data/app/services/action_agent/browser_sign_in.rb +163 -0
  94. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  95. data/app/services/action_agent/claude_code_auth.rb +70 -4
  96. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  97. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  98. data/app/services/action_agent/evaluation_fix.rb +101 -0
  99. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  100. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  101. data/app/services/action_agent/evaluation_standing.rb +15 -8
  102. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  103. data/app/services/action_agent/github_client.rb +405 -64
  104. data/app/services/action_agent/live_updates.rb +31 -0
  105. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  106. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  107. data/app/services/action_agent/mcp_client.rb +6 -2
  108. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  109. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  110. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  111. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  112. data/app/services/action_agent/project_boot_fix.rb +309 -0
  113. data/app/services/action_agent/project_capabilities.rb +200 -0
  114. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  115. data/app/services/action_agent/project_preflight.rb +178 -0
  116. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  117. data/app/services/action_agent/project_setup.rb +429 -0
  118. data/app/services/action_agent/provider_credentials.rb +102 -0
  119. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  120. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  121. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  122. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  123. data/app/services/action_agent/sandbox_browser.rb +299 -0
  124. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  125. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  126. data/app/services/action_agent/sandbox_mail.rb +103 -0
  127. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  128. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  129. data/app/services/action_agent/sandbox_patch.rb +253 -0
  130. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  131. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  132. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  133. data/app/services/action_agent/secret_scrubber.rb +33 -4
  134. data/app/services/action_agent/session_index.rb +270 -0
  135. data/app/services/action_agent/session_recording_service.rb +11 -0
  136. data/app/services/action_agent/session_timeline.rb +505 -0
  137. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  138. data/app/views/action_agent/session_player/show.html.erb +19 -0
  139. data/config/routes.rb +155 -1
  140. data/lib/action_agent/claude_login_process.rb +107 -0
  141. data/lib/action_agent/engine.rb +55 -5
  142. data/lib/action_agent/secret_requests.rb +80 -0
  143. data/lib/action_agent/version.rb +1 -1
  144. data/lib/action_agent.rb +507 -22
  145. data/lib/generators/action_agent/install_generator.rb +37 -0
  146. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  147. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  148. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  149. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  150. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  151. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  152. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  153. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  154. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  155. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  156. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  157. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  158. data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
  159. data/lib/tasks/action_agent.rake +33 -0
  160. data/lib/tasks/browser.rake +22 -0
  161. data/lib/tasks/sandbox.rake +1 -1
  162. data/lib/tasks/scenario_catalogs.rake +71 -0
  163. metadata +100 -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 user when it records one. Otherwise the owner, unless the
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 @api_key.user if @api_key.respond_to?(:user) && @api_key.user
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 key's owner is the identity that authenticated this request, so
137
- # it is the default; a host issuing keys per end user overrides it
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
- @api_key.respond_to?(:user) && @api_key.user ? @api_key.user : @owner
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 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)."
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.failed?
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