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.
Files changed (159) 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/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +26 -2
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +39 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -7,7 +7,13 @@ module ActionAgent
7
7
 
8
8
  # Provision a Cloud Run sandbox for the session
9
9
  # Each sandbox is an instance of the ActiveAgents application running in sandbox mode
10
- def perform(sandbox_session_id)
10
+ #
11
+ # @param options [Hash] for a checkout, at most one of
12
+ # "boot" => how to boot it, as SandboxBootSpec.request_options
13
+ # returns it; without it, "auto"
14
+ # "resume" => { "from" => a step name or nil }, to continue a failed
15
+ # boot its backend kept (SandboxSession#resume_boot!)
16
+ def perform(sandbox_session_id, options = {})
11
17
  # Deleted before the job ran: nothing to provision. `find` raised here,
12
18
  # and the rescue below then called update! on nil.
13
19
  sandbox = SandboxSession.find_by(id: sandbox_session_id)
@@ -29,10 +35,26 @@ module ActionAgent
29
35
  # Hand off to whichever backend this install registered — the engine
30
36
  # ships the in-memory one and :local, so a real container/job comes
31
37
  # from the host app's backend (see ActionAgent.sandbox_backends).
32
- ensure_checkout_available!(sandbox) if sandbox.app_runtime?
38
+ if sandbox.app_runtime?
39
+ ensure_checkout_available!(sandbox)
40
+ mint_checkout_token!(sandbox)
41
+ end
33
42
 
34
43
  orchestrator = SandboxOrchestrator.new
35
- result = orchestrator.create_sandbox(sandbox)
44
+ options = options.is_a?(Hash) ? options : {}
45
+ # A project's checkout boots from the project's spec, built here so its
46
+ # secrets' values are read in this process and never enqueued.
47
+ project = sandbox.app_runtime? ? sandbox.project : nil
48
+ result =
49
+ if options["resume"].is_a?(Hash)
50
+ orchestrator.resume_boot(sandbox, from: options["resume"]["from"].presence, boot_config: project&.boot_spec(sandbox))
51
+ elsif project
52
+ orchestrator.create_sandbox(sandbox, boot_config: project.boot_spec(sandbox))
53
+ elsif (spec = sandbox.app_runtime? && boot_spec(options["boot"]))
54
+ orchestrator.create_sandbox(sandbox, boot_config: spec)
55
+ else
56
+ orchestrator.create_sandbox(sandbox)
57
+ end
36
58
  # From here on the backend runs a sandbox for this session: whatever
37
59
  # goes wrong below, the rescue releases it unless the session recorded
38
60
  # its handle.
@@ -46,6 +68,7 @@ module ActionAgent
46
68
  return
47
69
  end
48
70
 
71
+ project&.sandbox_ready!(sandbox, app_models: result[:app_models])
49
72
  # Broadcast status update
50
73
  broadcast_sandbox_update(sandbox)
51
74
  rescue StandardError => e
@@ -65,6 +88,21 @@ module ActionAgent
65
88
 
66
89
  private
67
90
 
91
+ # The boot spec a checkout's boot options ask for, or nil to boot it as
92
+ # its sandbox.yml says. A spec "auto" asked for that cannot be built
93
+ # (the engine's gems come from a git URL with credentials in it) is
94
+ # dropped with a warning, so a checkout that bundles the engine still
95
+ # boots. One asked for with "always" fails the boot with the reason.
96
+ def boot_spec(boot)
97
+ boot = boot.is_a?(Hash) ? boot : {}
98
+ SandboxBootSpec.for_request(boot)
99
+ rescue SandboxBootSpec::Invalid => e
100
+ raise "Sandbox boot spec is invalid: #{e.message}" if %w[always true].include?(boot["bootstrap"].to_s)
101
+
102
+ Rails.logger.warn("[ActionAgent] not bootstrapping checkouts without the engine: #{e.message}")
103
+ nil
104
+ end
105
+
68
106
  def simulate_provisioning(sandbox)
69
107
  # Simulate a small delay for provisioning
70
108
  sleep(0.5)
@@ -79,7 +117,12 @@ module ActionAgent
79
117
  # selection, between creating the session and this job running.
80
118
  # checkout_spec answers nil for the first and raises ArgumentError for
81
119
  # the second; both mean the same thing to the owner.
120
+ # A GitHub App installation can also have been unlinked, or found removed
121
+ # or suspended by an earlier mint.
82
122
  def ensure_checkout_available!(sandbox)
123
+ installation = sandbox.checkout_installation
124
+ raise reinstall_message(sandbox, installation.removed_at ? :removed : :suspended) if installation && !installation.usable?
125
+
83
126
  available = begin
84
127
  sandbox.checkout_spec.present?
85
128
  rescue ArgumentError
@@ -90,6 +133,30 @@ module ActionAgent
90
133
  raise "#{sandbox.repository} is no longer available: reconnect GitHub or reselect it in Settings -> Integrations"
91
134
  end
92
135
 
136
+ # The one mint of a provision (see SandboxSession#mint_checkout_spec!).
137
+ # The token stays on +sandbox+, the object the orchestrator hands the
138
+ # backend and #secrets_for reads, so the backend clones with exactly the
139
+ # value this job scrubs.
140
+ def mint_checkout_token!(sandbox)
141
+ sandbox.mint_checkout_spec!
142
+ rescue GithubClient::InstallationUnavailable => e
143
+ raise reinstall_message(sandbox, e.reason)
144
+ rescue GithubClient::Error => e
145
+ raise "Could not get a GitHub token to check out #{sandbox.repository}: #{e.message}"
146
+ end
147
+
148
+ def reinstall_message(sandbox, reason)
149
+ installation = sandbox.checkout_installation
150
+ on = installation ? " on #{installation.github_account_login}" : ""
151
+ if reason == :suspended
152
+ "The GitHub App installation#{on} is suspended, so #{sandbox.repository} cannot be checked out. " \
153
+ "Once it is unsuspended on GitHub, use Check again in Settings -> Integrations and start the sandbox again."
154
+ else
155
+ "The GitHub App installation#{on} was removed, so #{sandbox.repository} cannot be checked out. " \
156
+ "Reinstall the GitHub App in Settings -> Integrations and start the sandbox again."
157
+ end
158
+ end
159
+
93
160
  # Marks the session ready with the backend's endpoint, under a row lock
94
161
  # so a concurrent DELETE either lands first (and this reports false) or
95
162
  # finds the session ready with a handle to terminate: SandboxSession#expire!
@@ -134,13 +201,16 @@ module ActionAgent
134
201
  return unless sandbox.provisioning?
135
202
 
136
203
  sandbox.update!(status: :failed, error_message: message)
204
+ sandbox.project&.sandbox_failed!(sandbox)
137
205
  broadcast_sandbox_update(sandbox)
138
206
  rescue ActiveRecord::RecordNotFound
139
207
  nil
140
208
  end
141
209
 
142
- # What must never reach error_message: the checkout token and the Claude
143
- # Code credential this session boots with.
210
+ # What must never reach error_message: the checkout token, the Claude
211
+ # Code credential this session boots with, and its project's secrets.
212
+ # Reads the checkout without minting, so a GitHub App checkout
213
+ # contributes the token this job minted.
144
214
  def secrets_for(sandbox)
145
215
  return [] if sandbox.nil?
146
216
 
@@ -149,16 +219,13 @@ module ActionAgent
149
219
  rescue StandardError
150
220
  nil
151
221
  end
152
- [ spec&.dig(:token), *sandbox.runtime_environment.values ].compact
222
+ [ spec&.dig(:token), *sandbox.runtime_environment.values, *sandbox.project_scrub_values ].compact
153
223
  rescue StandardError
154
224
  []
155
225
  end
156
226
 
157
227
  def broadcast_sandbox_update(sandbox)
158
- ActionCable.server.broadcast(
159
- "sandbox_#{sandbox.session_id}",
160
- { type: "status_update", sandbox: sandbox.summary }
161
- )
228
+ LiveUpdates.broadcast("sandbox_#{sandbox.session_id}", type: "status_update", id: sandbox.session_id, status: sandbox.status)
162
229
  end
163
230
  end
164
231
  end
@@ -18,7 +18,7 @@ module ActionAgent
18
18
  started_at = Time.current
19
19
 
20
20
  # Broadcast that run has started
21
- broadcast_run_started(sandbox, run_id, task, provider)
21
+ broadcast_run_started(sandbox, run_id)
22
22
 
23
23
  begin
24
24
  # Use ActiveAgent with generate_later for async processing
@@ -32,7 +32,7 @@ module ActionAgent
32
32
  rescue => e
33
33
  Rails.logger.error("Sandbox run failed: #{e.message}")
34
34
  back_to_ready(sandbox, error_message: e.message)
35
- broadcast_run_error(sandbox, run_id, provider, e.message)
35
+ broadcast_run_error(sandbox, run_id)
36
36
  end
37
37
  end
38
38
 
@@ -247,46 +247,18 @@ module ActionAgent
247
247
  PROMPT
248
248
  end
249
249
 
250
- def broadcast_run_started(sandbox, run_id, task, provider)
251
- ActionCable.server.broadcast(
252
- "sandbox_#{sandbox.session_id}",
253
- {
254
- type: "run_started",
255
- run_id: run_id,
256
- provider: provider,
257
- task: task,
258
- started_at: Time.current.iso8601
259
- }
260
- )
261
- Rails.logger.info "[SandboxRunJob] Broadcast run_started for #{provider} (#{run_id})"
250
+ # The run messages carry the job's run_id; the run itself is read back
251
+ # from the session.
252
+ def broadcast_run_started(sandbox, run_id)
253
+ LiveUpdates.broadcast("sandbox_#{sandbox.session_id}", type: "run_started", id: run_id, status: "running")
262
254
  end
263
255
 
264
256
  def broadcast_run_complete(sandbox, run_id, run)
265
- ActionCable.server.broadcast(
266
- "sandbox_#{sandbox.session_id}",
267
- {
268
- type: "run_complete",
269
- run_id: run_id,
270
- provider: run[:provider],
271
- run: run,
272
- sandbox: sandbox.summary
273
- }
274
- )
275
- Rails.logger.info "[SandboxRunJob] Broadcast run_complete for #{run[:provider]} (#{run_id})"
257
+ LiveUpdates.broadcast("sandbox_#{sandbox.session_id}", type: "run_complete", id: run_id, status: run[:status])
276
258
  end
277
259
 
278
- def broadcast_run_error(sandbox, run_id, provider, error)
279
- ActionCable.server.broadcast(
280
- "sandbox_#{sandbox.session_id}",
281
- {
282
- type: "run_error",
283
- run_id: run_id,
284
- provider: provider,
285
- error: error,
286
- sandbox: sandbox.summary
287
- }
288
- )
289
- Rails.logger.info "[SandboxRunJob] Broadcast run_error for #{provider} (#{run_id})"
260
+ def broadcast_run_error(sandbox, run_id)
261
+ LiveUpdates.broadcast("sandbox_#{sandbox.session_id}", type: "run_error", id: run_id, status: "failed")
290
262
  end
291
263
  end
292
264
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class VerifyEvaluationFixJob < ApplicationJob
5
+ queue_as :sandboxes
6
+
7
+ def perform(id)
8
+ session = CodeSession.find_by(id: id)
9
+ return unless session&.succeeded? && session.evaluation_run_id && session.diff.present?
10
+ sandbox = session.sandbox_session
11
+ run = nil
12
+ # Claim the verification under the checkout's lock: the pending run is
13
+ # what CodeSessionsController#busy_session waits on, so no later edit
14
+ # can race it. The restart happens after the lock is released, since
15
+ # it takes as long as a boot and the lock is a database row lock.
16
+ sandbox.with_lock do
17
+ session.reload
18
+ return if session.verification_run_id
19
+ raise "No reviewable git diff was captured for this fix" unless session.diff.start_with?("diff --git ")
20
+ raise "The sandbox is no longer running" unless sandbox.ready? && sandbox.active?
21
+ raise "Agent execution is disabled" unless ActionAgent.execution_enabled?
22
+ EvaluationFix.check_scenarios!(session.evaluation_run.evaluation, session.fix_item)
23
+ owner = session.evaluation_run.evaluation.agent.owner
24
+ raise "The sandbox is no longer available to the agent" unless SandboxSession.runtime_server_entry(sandbox.runtime_server_key, owner: owner)
25
+ selection = { "sandbox_id" => sandbox.session_id, "keys" => session.fix_item.fetch("scenario_keys"), "models" => session.fix_item.fetch("models") }
26
+ run = session.evaluation_run.evaluation.evaluation_runs.create!(status: :pending, selection: selection)
27
+ session.update!(verification_run: run, verification_error: nil)
28
+ end
29
+ SandboxOrchestrator.new.refresh_runtime(sandbox)
30
+ EvaluationRunJob.perform_later(run.evaluation_id, run.id, run.selection)
31
+ rescue StandardError => error
32
+ message = SecretScrubber.scrub(error.message, session&.secrets).truncate(1000)
33
+ run&.update!(status: :failed, error_message: message, completed_at: Time.current)
34
+ session&.update!(verification_error: message)
35
+ end
36
+ end
37
+ end
@@ -88,7 +88,7 @@ module ActionAgent
88
88
  # serialize. This constant stays the built-in set so existing references
89
89
  # keep their meaning.
90
90
  AVAILABLE_TOOLS = %w[
91
- terminal playwright filesystem code database slack fetch search edit translate memory agents ui
91
+ terminal playwright filesystem code database slack fetch search edit translate memory agents ui ask
92
92
  ].freeze
93
93
 
94
94
  # One line per capability, for the roster rows that offer them. A name
@@ -107,7 +107,8 @@ module ActionAgent
107
107
  "translate" => "Translates text through the translation agent.",
108
108
  "memory" => "Reads and writes durable notes across runs of this agent.",
109
109
  "agents" => "Delegates a task to another agent in this workspace.",
110
- "ui" => "Renders a form or table back into the chat surface."
110
+ "ui" => "Renders a form or table back into the chat surface.",
111
+ "ask" => "Pauses the run to ask the user a question or for approval, and continues with the answer."
111
112
  }.freeze
112
113
 
113
114
  # Every tool an agent may enable: the built-ins plus each tool generated by
@@ -246,16 +247,28 @@ module ActionAgent
246
247
  appearance: appearance,
247
248
  instruction_sets: instruction_sets,
248
249
  tools: tools,
250
+ approval_required_tools: approval_required_tools,
249
251
  mcp_servers: mcp_servers,
250
252
  model_config: model_config,
251
253
  response_format: response_format
252
254
  }
253
255
  end
254
256
 
257
+ # The tool names whose calls wait for a person to approve them before
258
+ # they run: toolbox tools, schema tools and the agent's MCP servers'
259
+ # tools alike. Empty on an install that has not run the migration adding
260
+ # the column.
261
+ # @return [Array<String>]
262
+ def approval_required_tools
263
+ return [] unless has_attribute?(:approval_required_tools)
264
+
265
+ Array(self[:approval_required_tools]).map(&:to_s).reject(&:blank?).uniq
266
+ end
267
+
255
268
  # Restore from a version
256
269
  def restore_from_version!(version)
257
270
  config = version.configuration_snapshot
258
- update!(
271
+ attributes = {
259
272
  instructions: config["instructions"],
260
273
  action_prompts: config["action_prompts"] || [],
261
274
  preset_type: config["preset_type"],
@@ -265,7 +278,9 @@ module ActionAgent
265
278
  mcp_servers: config["mcp_servers"],
266
279
  model_config: config["model_config"],
267
280
  response_format: config["response_format"]
268
- )
281
+ }
282
+ attributes[:approval_required_tools] = config["approval_required_tools"] || [] if has_attribute?(:approval_required_tools)
283
+ update!(attributes)
269
284
  end
270
285
 
271
286
  # Get the latest version
@@ -405,12 +420,16 @@ module ActionAgent
405
420
  # run reaches as if the agent had it in mcp_servers, without saving it on
406
421
  # the agent. The caller checks the sandbox is theirs and live; the
407
422
  # dispatcher still resolves it among this agent's owner's sessions only.
423
+ #
424
+ # A block is given the run before its job is enqueued, so whatever it
425
+ # records about the run is there when the job starts.
408
426
  def execute(input_prompt, action: nil, attachments: [], actor: nil, runtime_sandbox: nil, **params)
409
427
  ensure_executable!
410
428
  run = create_run(
411
429
  input_prompt, action: action, attachments: attachments, params: params,
412
430
  actor: actor, runtime_sandbox: runtime_sandbox, status: :pending
413
431
  )
432
+ yield run if block_given?
414
433
 
415
434
  # Queue the execution job
416
435
  AgentExecutionJob.perform_later(run.id)
@@ -418,7 +437,9 @@ module ActionAgent
418
437
  run
419
438
  end
420
439
 
421
- # Quick test execution (synchronous)
440
+ # Quick test execution (synchronous). A run that pauses for input comes
441
+ # back `awaiting_input` with its requests stored, and AgentResumeJob
442
+ # finishes it once they are answered.
422
443
  def test_execute(input_prompt, action: nil, attachments: [], actor: nil, runtime_sandbox: nil, **params)
423
444
  ensure_executable!
424
445
  run = create_run(
@@ -431,27 +452,9 @@ module ActionAgent
431
452
  # Build and execute the agent
432
453
  result = AgentExecutionService.call(self, run)
433
454
 
434
- run.update!(
435
- output: result[:output],
436
- output_metadata: result[:metadata],
437
- status: :complete,
438
- completed_at: Time.current,
439
- duration_ms: ((Time.current - run.started_at) * 1000).to_i,
440
- input_tokens: result.dig(:usage, :input_tokens),
441
- output_tokens: result.dig(:usage, :output_tokens),
442
- total_tokens: result.dig(:usage, :total_tokens)
443
- )
455
+ run.record_result!(result, segment_started_at: run.started_at)
444
456
  rescue => e
445
- run.update!(
446
- status: :failed,
447
- completed_at: Time.current,
448
- error_message: e.message,
449
- error_backtrace: e.backtrace&.first(10)&.join("\n"),
450
- # The class, not only the message: an agent that refused this
451
- # caller and an agent that broke both fail the run, and only the
452
- # class tells them apart without reading prose.
453
- output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
454
- )
457
+ run.record_failure!(e)
455
458
  end
456
459
 
457
460
  run
@@ -490,6 +493,8 @@ module ActionAgent
490
493
  input_params = AgentRun.params_with_actor(params, actor)
491
494
  if SandboxSession.runtime_server_key?(runtime_sandbox)
492
495
  input_params = input_params.merge(AgentRun::SANDBOX_PARAM => runtime_sandbox.to_s)
496
+ browser = running_browser_key(runtime_sandbox)
497
+ input_params = input_params.merge(AgentRun::BROWSER_PARAM => browser) if browser
493
498
  end
494
499
 
495
500
  run = agent_runs.create!(
@@ -562,7 +567,7 @@ module ActionAgent
562
567
 
563
568
  VERSIONED_FIELDS = %w[
564
569
  instructions action_prompts preset_type appearance instruction_sets
565
- tools mcp_servers model_config response_format
570
+ tools approval_required_tools mcp_servers model_config response_format
566
571
  ].freeze
567
572
 
568
573
  def configuration_changed?
@@ -580,6 +585,14 @@ module ActionAgent
580
585
  )
581
586
  end
582
587
 
588
+ # The browser key of the sandbox +runtime_sandbox+ names, when that
589
+ # sandbox is this agent's owner's and its browser is running.
590
+ def running_browser_key(runtime_sandbox)
591
+ session_id = runtime_sandbox.to_s.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)
592
+ sandbox = SandboxSession.for_owner(owner).find_by(session_id: session_id)
593
+ sandbox.browser_server_key if sandbox&.browser_running?
594
+ end
595
+
583
596
  # Unknown action names fall back to the default rather than failing the
584
597
  # run — an action can be renamed between enqueue and execution.
585
598
  def normalized_action(action)
@@ -7,6 +7,9 @@ module ActionAgent
7
7
  # time, since a run is against the agent as it is.
8
8
  belongs_to :agent_version, optional: true
9
9
  before_create { self.agent_version_id ||= agent&.latest_version&.id }
10
+ has_many :input_requests, as: :subject, inverse_of: :subject, dependent: :destroy
11
+ has_many :pending_input_requests, -> { pending.for_listing.order(:id) },
12
+ class_name: "ActionAgent::InputRequest", as: :subject, inverse_of: :subject
10
13
 
11
14
  # Raised when a caller hands a run files to attach in a host app that
12
15
  # has nowhere to keep them.
@@ -20,9 +23,10 @@ module ActionAgent
20
23
  # the model (images and PDFs as data URIs, text inlined) and the
21
24
  # persisted user message keeps a manifest of them.
22
25
  #
23
- # Guarded like RecordingSnapshot: a host app created with
24
- # --skip-active-storage has no has_many_attached to call.
25
- has_many_attached :attachments if defined?(ActiveStorage)
26
+ # Per ActionAgent.active_storage, like RecordingSnapshot: a host app
27
+ # created with --skip-active-storage has no has_many_attached to call,
28
+ # and a host may switch attachments off.
29
+ has_many_attached :attachments, **ActionAgent.attachment_options if ActionAgent.active_storage_macros?
26
30
 
27
31
  # The key the caller's identity is recorded under in +input_params+.
28
32
  # Underscored so it cannot collide with a provider override, and
@@ -33,6 +37,11 @@ module ActionAgent
33
37
  # the server, after it checked the caller owns that sandbox, and
34
38
  # stripped from anything a client sends, as the actor is.
35
39
  SANDBOX_PARAM = "_sandbox_server"
40
+ # The key of that sandbox's browser ("browser:<session_id>"), recorded
41
+ # when the browser was running as the run was created, so a run whose
42
+ # browser stops before it executes fails rather than running without
43
+ # it. Set and stripped like SANDBOX_PARAM.
44
+ BROWSER_PARAM = "_sandbox_browser"
36
45
 
37
46
  # +input_params+ with the caller recorded alongside them.
38
47
  #
@@ -47,7 +56,8 @@ module ActionAgent
47
56
  # @param actor [Object, nil] the caller
48
57
  # @return [Hash]
49
58
  def self.params_with_actor(params, actor)
50
- params = (params || {}).to_h.except(ACTOR_PARAM, ACTOR_PARAM.to_sym, SANDBOX_PARAM, SANDBOX_PARAM.to_sym)
59
+ params = (params || {}).to_h.except(ACTOR_PARAM, ACTOR_PARAM.to_sym, SANDBOX_PARAM, SANDBOX_PARAM.to_sym,
60
+ BROWSER_PARAM, BROWSER_PARAM.to_sym)
51
61
  gid = actor.respond_to?(:to_global_id) ? actor.to_global_id.to_s : nil
52
62
  gid ? params.merge(ACTOR_PARAM => gid) : params
53
63
  rescue StandardError => e
@@ -83,6 +93,14 @@ module ActionAgent
83
93
  sandbox_server_key&.delete_prefix(SandboxSession::RUNTIME_SERVER_PREFIX)
84
94
  end
85
95
 
96
+ # The "browser:<session_id>" key of that sandbox's browser, when it was
97
+ # running as the run was created, or nil.
98
+ # @return [String, nil]
99
+ def browser_server_key
100
+ key = input_params[BROWSER_PARAM] if input_params.is_a?(Hash)
101
+ key.to_s.presence if sandbox_server_key && SandboxSession.browser_server_key?(key)
102
+ end
103
+
86
104
  # Whether this run knows who it is for. A run with a recorded actor that
87
105
  # no longer resolves is *not* unattributed — it is broken, and callers
88
106
  # that care can tell the two apart.
@@ -91,14 +109,13 @@ module ActionAgent
91
109
  input_params.is_a?(Hash) && input_params[ACTOR_PARAM].present?
92
110
  end
93
111
 
94
- # Whether runs can carry files in this host app: Active Storage loaded,
95
- # the macro applied, and its tables migrated. Never raises — a host
96
- # that skipped `rails active_storage:install` still runs agents, it
97
- # just can't attach files to them.
112
+ # Whether runs can carry files in this host app: Active Storage on per
113
+ # ActionAgent.active_storage, the macro applied, and its tables
114
+ # migrated. Never raises — a host that skipped
115
+ # `rails active_storage:install` still runs agents, it just can't attach
116
+ # files to them.
98
117
  def self.attachments_available?
99
- defined?(ActiveStorage) && method_defined?(:attachments) && ActiveStorage::Blob.table_exists?
100
- rescue StandardError
101
- false
118
+ ActionAgent.active_storage_available? && method_defined?(:attachments)
102
119
  end
103
120
 
104
121
  # How an attachment reaches the model, by MIME type with a filename
@@ -118,8 +135,9 @@ module ActionAgent
118
135
  "file"
119
136
  end
120
137
 
121
- # Status enum
122
- enum :status, { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }
138
+ # `awaiting_input` is a run paused until a person answers its
139
+ # InputRequests; AgentResumeJob continues it.
140
+ enum :status, { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4, awaiting_input: 5 }
123
141
 
124
142
  # Validations
125
143
  validates :trace_id, presence: true
@@ -129,6 +147,23 @@ module ActionAgent
129
147
  scope :successful, -> { where(status: :complete) }
130
148
  scope :failed_runs, -> { where(status: :failed) }
131
149
  scope :today, -> { where("created_at >= ?", Time.current.beginning_of_day) }
150
+ # Runs executed on behalf of +actor+, matched on the Global ID recorded
151
+ # under ACTOR_PARAM. None for an actor without one.
152
+ scope :on_behalf_of, ->(actor) {
153
+ gid = actor.respond_to?(:to_global_id) ? actor.to_global_id.to_s : nil
154
+ gid ? where("#{input_param_sql(ACTOR_PARAM)} = ?", gid) : none
155
+ }
156
+
157
+ # SQL reading +key+ of input_params as text on the connected database.
158
+ # +key+ is always a literal from this codebase, never user input.
159
+ def self.input_param_sql(key)
160
+ column = "#{quoted_table_name}.input_params"
161
+ case connection.adapter_name.to_s.downcase
162
+ when /postgres/ then "#{column} ->> '#{key}'"
163
+ when /mysql|trilogy/ then "JSON_UNQUOTE(JSON_EXTRACT(#{column}, '$.#{key}'))"
164
+ else "json_extract(#{column}, '$.#{key}')"
165
+ end
166
+ end
132
167
 
133
168
  # Callbacks
134
169
  before_validation :set_trace_id, on: :create
@@ -271,27 +306,103 @@ module ActionAgent
271
306
  }
272
307
  end
273
308
 
274
- # Stream output updates via ActionCable
309
+ # Tells subscribers of this run's stream, and of its agent's, that the
310
+ # run's status changed.
275
311
  def broadcast_update
276
- payload = { type: "update", run: summary }
277
- ActionCable.server.broadcast("agent_run_#{id}", payload)
278
- ActionCable.server.broadcast("agent_runs_#{agent_id}", payload)
312
+ LiveUpdates.broadcast("agent_run_#{id}", type: "update", id: id, status: status)
313
+ LiveUpdates.broadcast("agent_runs_#{agent_id}", type: "update", id: id, status: status)
279
314
  end
280
315
 
281
- # Cancel a running execution
282
- def cancel!
283
- return unless in_progress?
316
+ # Cancels a run that is in progress or waiting for input, and every
317
+ # request for input it still has pending.
318
+ #
319
+ # @param reason [String] recorded as the run's error message
320
+ def cancel!(reason = "Cancelled by user")
321
+ return unless in_progress? || awaiting_input?
322
+
323
+ transaction do
324
+ update!(status: :cancelled, completed_at: Time.current, error_message: reason)
325
+ input_requests.pending.update_all(status: InputRequest.statuses[:cancelled], updated_at: Time.current)
326
+ end
327
+ broadcast_update
328
+ end
329
+
330
+ # Expires the run's overdue requests for input, which fails the run (see
331
+ # InputRequest#expire!). Returns the run, reloaded when a request
332
+ # expired.
333
+ def expire_overdue_input_requests!
334
+ return self unless awaiting_input?
335
+
336
+ InputRequest.expire_overdue!(input_requests).positive? ? reload : self
337
+ end
284
338
 
339
+ # Writes one execution segment's result: `complete` with its output, or,
340
+ # for a generation that paused, `awaiting_input` with one InputRequest
341
+ # per paused tool call, stored in the same transaction so an answer can
342
+ # never find the requests before the run waits on them. Tokens and
343
+ # duration add to what earlier segments of the run recorded.
344
+ #
345
+ # @param result [Hash] AgentExecutionService's result
346
+ # @param segment_started_at [Time] when this segment started executing
347
+ def record_result!(result, segment_started_at:)
348
+ usage = result[:usage] || {}
349
+ attributes = {
350
+ output_metadata: result[:metadata],
351
+ duration_ms: duration_ms.to_i + ((Time.current - segment_started_at) * 1000).to_i,
352
+ input_tokens: add_tokens(input_tokens, usage[:input_tokens]),
353
+ output_tokens: add_tokens(output_tokens, usage[:output_tokens]),
354
+ total_tokens: add_tokens(total_tokens, usage[:total_tokens])
355
+ }
356
+
357
+ transaction do
358
+ if result[:input_requests].present?
359
+ InputRequest.record_pause!(self, result[:input_requests], checkpoint: result[:checkpoint])
360
+ update!(attributes.merge(status: :awaiting_input))
361
+ else
362
+ update!(attributes.merge(output: result[:output], status: :complete, completed_at: Time.current))
363
+ end
364
+ end
365
+ end
366
+
367
+ # Fails the run with +error+, recording its class beside the message: the
368
+ # class is what tells a refusal from a crash without reading prose.
369
+ #
370
+ # @param error [Exception]
371
+ # @param message [String] the message to record, when it must differ from
372
+ # the error's own
373
+ def record_failure!(error, message: error.message)
285
374
  update!(
286
- status: :cancelled,
375
+ status: :failed,
287
376
  completed_at: Time.current,
288
- error_message: "Cancelled by user"
377
+ error_message: message,
378
+ error_backtrace: error.backtrace&.first(10)&.join("\n"),
379
+ output_metadata: output_metadata.to_h.merge("error_class" => error.class.name)
289
380
  )
290
- broadcast_update
381
+ end
382
+
383
+ # Yields under the run's row lock unless the run was cancelled meanwhile,
384
+ # so a cancel that arrived while the run executed is never overwritten by
385
+ # the result. Returns whether it yielded.
386
+ def unless_cancelled
387
+ with_lock do
388
+ if cancelled?
389
+ add_log("Execution finished after cancellation; result discarded", level: :info)
390
+ next false
391
+ end
392
+
393
+ yield
394
+ true
395
+ end
291
396
  end
292
397
 
293
398
  private
294
399
 
400
+ def add_tokens(recorded, segment)
401
+ return recorded if segment.nil?
402
+
403
+ recorded.to_i + segment.to_i
404
+ end
405
+
295
406
  def locate_actor
296
407
  return nil unless actor_recorded?
297
408
  return nil unless defined?(GlobalID::Locator)