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
@@ -27,6 +27,15 @@ module ActionAgent
27
27
  # agent's owner's sessions, since the run may start well after it was
28
28
  # asked for; one that is no longer live fails the run rather than
29
29
  # replaying without the tools it was meant to test.
30
+ #
31
+ # A selection with `browser` true (a run of a project's evaluation, see
32
+ # ProjectEvaluationJob) also gives every replay that sandbox's browser:
33
+ # one already running, or one started headless with the project's saved
34
+ # sign-in before the first replay, on a backend that runs browsers.
35
+ # `mount_url`, the dashboard's absolute mount URL, is where a browser
36
+ # started here posts its recording. Each replay's browser opens at the
37
+ # project's start URL. A browser that cannot start fails the run before
38
+ # any replay, and one started here is stopped once the run ends.
30
39
  class ScenarioEvaluationRunner < EvaluationRunnerService
31
40
  Evals = ActiveAgent::Evals
32
41
 
@@ -41,6 +50,22 @@ module ActionAgent
41
50
  new(evaluation, selection: selection, run: run).call
42
51
  end
43
52
 
53
+ # The caller a replay of +owner+'s agent runs on behalf of, which is also
54
+ # who its provider credentials are resolved for: +owner+, when the
55
+ # install owns agents per user. A run with no caller reads, through any
56
+ # host scope, as "no access" — every tool answers empty and the suite
57
+ # grades an agent that never saw a row — so the person the evaluation
58
+ # belongs to is the right default, as the key's owner is over MCP. An
59
+ # account is who is billed, not who is allowed (see Api::BaseController
60
+ # #agent_actor), so a multi-tenant install replays unattributed unless a
61
+ # host adapter (ActionAgent.scenario_evaluation_adapter_resolver) runs
62
+ # the suite itself.
63
+ def self.replay_actor_for(owner)
64
+ return nil if ActionAgent.multi_tenant? || ActionAgent.user_class.blank?
65
+
66
+ owner
67
+ end
68
+
44
69
  def initialize(evaluation, selection: {}, run: nil)
45
70
  super(evaluation)
46
71
  @selection = (selection || {}).to_h.with_indifferent_access
@@ -60,6 +85,8 @@ module ActionAgent
60
85
  return run
61
86
  end
62
87
 
88
+ ensure_browser_live!
89
+
63
90
  records = scenarios.index_by(&:key)
64
91
  tasks = scenarios.map { |scenario| Evals::Scenario.from_hash(scenario.as_json_summary) }
65
92
  expected = tasks.product(specs).map { |task, spec| [ task.key, spec.label ] }
@@ -111,6 +138,8 @@ module ActionAgent
111
138
  rescue StandardError => e
112
139
  run&.update!(status: :failed, error_message: e.message, completed_at: Time.current)
113
140
  raise
141
+ ensure
142
+ stop_started_browser
114
143
  end
115
144
 
116
145
  private
@@ -162,7 +191,8 @@ module ActionAgent
162
191
  "scenario_keys" => scenarios.map(&:key),
163
192
  "group" => @selection[:group].presence,
164
193
  "models" => specs.map(&:to_h),
165
- "sandbox" => sandbox_summary
194
+ "sandbox" => sandbox_summary,
195
+ "browser" => browser_requested? ? { "server_key" => "#{SandboxSession::BROWSER_SERVER_PREFIX}#{@selection[:sandbox_id]}" } : nil
166
196
  }.compact
167
197
  end
168
198
 
@@ -200,6 +230,66 @@ module ActionAgent
200
230
  raise ArgumentError, "Sandbox #{@selection[:sandbox_id]} is no longer running; start it again, or run without it"
201
231
  end
202
232
 
233
+ # --- browser ----------------------------------------------------------
234
+
235
+ # Whether the replays reach the sandbox's browser: asked for, and
236
+ # running or startable on the sandbox's backend.
237
+ def browser_requested?
238
+ return @browser_requested if defined?(@browser_requested)
239
+
240
+ @browser_requested = sandbox_server_key.present? && ActiveModel::Type::Boolean.new.cast(@selection[:browser]) == true &&
241
+ sandbox_session.present? && (sandbox_session.browser_running? || SandboxBrowser.orchestrator!.supports?(:start_browser))
242
+ rescue SandboxBrowser::Error
243
+ @browser_requested = false
244
+ end
245
+
246
+ # The project the selected sandbox was booted for, or nil.
247
+ def sandbox_project
248
+ return @sandbox_project if defined?(@sandbox_project)
249
+
250
+ @sandbox_project = sandbox_session&.project_id && Project.find_by(id: sandbox_session.project_id)
251
+ end
252
+
253
+ def ensure_browser_live!
254
+ return unless browser_requested?
255
+
256
+ sandbox = sandbox_session
257
+ unless sandbox.browser_running?
258
+ denial = ActionAgent.quota_denial(sandbox.metering_owner, :browser_minutes)
259
+ raise ArgumentError, "The sandbox's browser could not start: the plan allows no more browser minutes" if denial.present?
260
+
261
+ _, @started_browser = SandboxBrowser.ensure_running!(sandbox,
262
+ recording_url: SandboxBrowser.recording_url_for(@selection[:mount_url]), storage_state: sandbox_project&.saved_storage_state)
263
+ end
264
+ @sandbox_session = sandbox.reload
265
+ rescue SandboxBrowser::Error => e
266
+ raise ArgumentError, "The sandbox's browser could not start: #{e.message}"
267
+ end
268
+
269
+ # Stops the browser ensure_browser_live! started, which completes its
270
+ # recording and stops its minutes. One that was already running is left
271
+ # running.
272
+ def stop_started_browser
273
+ return unless @started_browser
274
+
275
+ @started_browser = false
276
+ sandbox = sandbox_session.reload
277
+ SandboxBrowser.stop(sandbox) if SandboxBrowser::STARTED.include?(sandbox.browser_status)
278
+ rescue SandboxBrowser::Error, ActiveRecord::RecordNotFound => e
279
+ Rails.logger.warn("[ActionAgent] evaluation #{@evaluation.id}: could not stop the browser it started: #{e.message}")
280
+ end
281
+
282
+ # Opens the project's start URL in the sandbox's browser, so each replay
283
+ # starts on the same page. A browser that does not answer is left to the
284
+ # replay, which fails when it cannot reach it.
285
+ def open_start_url
286
+ return unless browser_requested?
287
+
288
+ SandboxBrowserDriver.new(sandbox_session).open(sandbox_project&.start_url.presence || "/")
289
+ rescue SandboxBrowserDriver::Error => e
290
+ Rails.logger.warn("[ActionAgent] evaluation #{@evaluation.id}: could not open the start URL: #{e.message}")
291
+ end
292
+
203
293
  # --- replay -----------------------------------------------------------
204
294
 
205
295
  def replay(scenario, spec)
@@ -207,6 +297,7 @@ module ActionAgent
207
297
  # (the order SandboxesController#compare uses), so it is counted even
208
298
  # when the run fails.
209
299
  ActionAgent.record_usage(owner, :execution)
300
+ open_start_url
210
301
 
211
302
  agent_run = @evaluation.agent.test_execute(
212
303
  scenario.prompt,
@@ -215,6 +306,9 @@ module ActionAgent
215
306
  actor: replay_actor,
216
307
  runtime_sandbox: sandbox_server_key
217
308
  )
309
+ # A replay has nobody to answer, so a paused run ends here as an error.
310
+ paused = agent_run.awaiting_input?
311
+ agent_run.cancel!("Paused for input during an evaluation replay") if paused
218
312
 
219
313
  Evals::Replay.new(
220
314
  answer: agent_run.output,
@@ -222,13 +316,19 @@ module ActionAgent
222
316
  duration_ms: agent_run.calculated_duration_ms,
223
317
  input_tokens: agent_run.input_tokens,
224
318
  output_tokens: agent_run.output_tokens,
225
- error: agent_run.failed? ? agent_run.error_message.presence || "run failed" : nil,
319
+ error: replay_error(agent_run, paused),
226
320
  cost: ModelPricing.estimate(model: spec.model, provider: spec.provider, input_tokens: agent_run.input_tokens,
227
321
  output_tokens: agent_run.output_tokens),
228
322
  metadata: { "agent_run_id" => agent_run.id }
229
323
  )
230
324
  end
231
325
 
326
+ def replay_error(agent_run, paused)
327
+ return "paused for input" if paused
328
+
329
+ agent_run.failed? ? agent_run.error_message.presence || "run failed" : nil
330
+ end
331
+
232
332
  # The caller a replay runs on behalf of: the evaluation's owner, when the
233
333
  # install owns agents per user. A run with no caller reads, through any
234
334
  # host scope, as "no access" — every tool answers empty and the suite
@@ -239,9 +339,7 @@ module ActionAgent
239
339
  # host adapter (ActionAgent.scenario_evaluation_adapter_resolver) runs
240
340
  # the suite itself.
241
341
  def replay_actor
242
- return nil if ActionAgent.multi_tenant? || ActionAgent.user_class.blank?
243
-
244
- owner
342
+ self.class.replay_actor_for(owner)
245
343
  end
246
344
 
247
345
  # Each tool call the run made, rebuilt from the run's progress events
@@ -334,23 +432,28 @@ module ActionAgent
334
432
  # report "none of the available tools covers this task" while naming a list
335
433
  # the agent's MCP tools were missing from.
336
434
  def tool_roster
337
- @tool_roster ||= begin
338
- definitions = mcp_dispatcher.tool_definitions +
339
- AgentToolbox.definitions_for(@evaluation.agent.tools)
340
-
341
- definitions.to_h { |definition| [ definition[:name].to_s, definition[:description].to_s ] }
342
- end
435
+ runtime_tools.tools
343
436
  end
344
437
 
345
- # Discovery failures from the roster above, keyed by server. Reading them
346
- # requires tool_definitions to have run, which tool_roster does.
438
+ # Discovery failures from the roster above, keyed by server.
347
439
  def mcp_discovery_errors
348
- tool_roster
349
- mcp_dispatcher.discovery_errors
440
+ runtime_tools.discovery_errors
350
441
  end
351
442
 
443
+ # The sandbox's browser is on the roster while it runs, as each replay
444
+ # (Agent#test_execute) reaches it then.
352
445
  def mcp_dispatcher
353
- @mcp_dispatcher ||= MCPToolDispatcher.new(@evaluation.agent, extra_server_keys: [ sandbox_server_key ].compact)
446
+ runtime_tools.dispatcher
447
+ end
448
+
449
+ def runtime_tools
450
+ @runtime_tools ||= RuntimeToolRoster.new(
451
+ @evaluation.agent, extra_server_keys: [ sandbox_server_key, sandbox_browser_key ].compact
452
+ )
453
+ end
454
+
455
+ def sandbox_browser_key
456
+ sandbox_session.browser_server_key if sandbox_session&.browser_running?
354
457
  end
355
458
 
356
459
  # A run whose every declared MCP server failed discovery scored an agent
@@ -6,28 +6,57 @@ module ActionAgent
6
6
  # surface in a process's output (an error echoing a URL, a session that
7
7
  # prints its environment), and none of that may reach a transcript, a log
8
8
  # tail or an API response.
9
+ #
10
+ # Besides the values it is given, it masks anything shaped like a GitHub
11
+ # token, given or not. A GitHub App checkout's token is never stored, so a
12
+ # later code session in that checkout cannot name it by value.
9
13
  module SecretScrubber
10
14
  MASK = "[REDACTED]"
11
15
  # Shorter values are not credentials, and masking them would mangle text.
12
16
  MIN_SECRET_LENGTH = 8
17
+ # GitHub's token formats: personal (ghp_), OAuth (gho_), App user (ghu_),
18
+ # installation (ghs_) and refresh (ghr_) tokens, and fine-grained
19
+ # personal tokens (github_pat_). Matched wherever they start, so a token
20
+ # straight after a percent-escape (`%3Aghs_…` in an encoded URL) or a
21
+ # name (`GITHUB_TOKEN_ghs_…`) is masked too.
22
+ GITHUB_TOKEN = /gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}/
23
+ CLAUDE_TOKEN = /sk-ant-(?:oat|ort|api)[A-Za-z0-9_-]+/
13
24
 
14
25
  module_function
15
26
 
16
27
  # @param value [String, Hash, Array, Object] what to scrub
17
28
  # @param secrets [Array<String>] the values to mask
18
- # @return a copy of +value+ with every secret masked
29
+ # @return a copy of +value+ with every secret and every GitHub token masked
19
30
  def scrub(value, secrets)
20
31
  secrets = Array(secrets).compact.map(&:to_s).select { |secret| secret.length >= MIN_SECRET_LENGTH }.uniq
21
- return value if secrets.empty?
22
32
 
23
33
  # Longest first, so a secret that contains another is masked whole.
24
- pattern = Regexp.union(secrets.sort_by { |secret| -secret.length })
34
+ pattern = Regexp.union(*secrets.sort_by { |secret| -secret.length }, GITHUB_TOKEN, CLAUDE_TOKEN)
25
35
  deep_scrub(value, pattern)
26
36
  end
27
37
 
38
+ # +values+ with the other forms each can take in output: URL-encoded (as
39
+ # a form and as a path encode it), Base64 (standard and URL-safe, with
40
+ # and without padding) and escaped inside a JSON string (as JSON.stringify
41
+ # and as Rails' to_json escape it). Blank values are dropped.
42
+ #
43
+ # @param values [Array<String>]
44
+ # @return [Array<String>]
45
+ def with_encodings(values)
46
+ Array(values).compact.map(&:to_s).reject(&:empty?).flat_map do |value|
47
+ base64 = [ value ].pack("m0")
48
+ urlsafe = base64.tr("+/", "-_")
49
+ [ value, URI.encode_www_form_component(value), ERB::Util.url_encode(value),
50
+ base64, base64.delete("="), urlsafe, urlsafe.delete("="),
51
+ JSON.generate(value)[1...-1], value.to_json[1...-1] ]
52
+ end.uniq
53
+ end
54
+
28
55
  def deep_scrub(value, pattern)
29
56
  case value
30
- when String then value.gsub(pattern, MASK)
57
+ # A regexp cannot scan bytes that are invalid in the string's encoding,
58
+ # so those are replaced first.
59
+ when String then (value.valid_encoding? ? value : value.scrub).gsub(pattern, MASK)
31
60
  when Hash then value.to_h { |key, item| [ key, deep_scrub(item, pattern) ] }
32
61
  when Array then value.map { |item| deep_scrub(item, pattern) }
33
62
  else value
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to list the sessions a caller can replay, newest first, a page at a
5
+ # time.
6
+ #
7
+ # Kinds, and the source each one is filtered by:
8
+ # context dashboard a conversation (AgentContext) of one of the
9
+ # caller's agents
10
+ # scenario_result evaluation the run that replayed an evaluation scenario
11
+ # recording agent a browser recording linked to no
12
+ # conversation, made by no run that wrote to
13
+ # one, and made by no evaluation replay
14
+ #
15
+ # An evaluation replay writes to its agent's conversation as well, so a
16
+ # conversation whose every generation came from evaluation replays is left
17
+ # out: each replay is listed once, as its scenario result.
18
+ #
19
+ # A conversation's runs are the runs that wrote a generation to it and the
20
+ # runs pinned to it (input_params "context_id"). A session is placed by its
21
+ # last activity: a conversation's updated_at, a result's or a recording's
22
+ # created_at. Pages are read with a cursor, so a deep page costs what the
23
+ # first one does.
24
+ class SessionIndex
25
+ KINDS = { "context" => "dashboard", "scenario_result" => "evaluation", "recording" => "agent" }.freeze
26
+ SOURCES = KINDS.values.uniq.freeze
27
+ OUTCOMES = %w[failed passed].freeze
28
+ TIME_COLUMNS = { "context" => :updated_at, "scenario_result" => :created_at, "recording" => :created_at }.freeze
29
+ DEFAULT_PER_PAGE = 25
30
+ MAX_PER_PAGE = 100
31
+
32
+ # An actor that matches no session, for a "mine" filter with nobody
33
+ # signed in.
34
+ NO_ACTOR = Object.new.freeze
35
+
36
+ # Raised for a cursor the index cannot read.
37
+ class InvalidCursor < ArgumentError; end
38
+
39
+ # What narrows the index. Every member is optional.
40
+ #
41
+ # @!attribute agent_id
42
+ # @return [Integer] one of the caller's agents
43
+ # @!attribute actor
44
+ # @return [Object] who the sessions' runs were executed on behalf of
45
+ # (AgentRun.on_behalf_of)
46
+ # @!attribute user
47
+ # @return [Object] the signed-in user, whose own browser recordings
48
+ # match along with the actor's runs. Read only with +actor+.
49
+ # @!attribute source
50
+ # @return [String] one of SOURCES
51
+ # @!attribute outcome
52
+ # @return [String] "failed" (a failed or errored replay, a conversation
53
+ # with a failed run, a failed recording) or "passed" (a passed replay)
54
+ # @!attribute from
55
+ # @return [Time] the earliest last activity, inclusive
56
+ # @!attribute to
57
+ # @return [Time] the latest last activity, exclusive
58
+ Filters = Struct.new(:agent_id, :actor, :user, :source, :outcome, :from, :to, keyword_init: true)
59
+
60
+ # One position in the index: sessions after it sort older.
61
+ Cursor = Struct.new(:time, :kind, :id) do
62
+ # Reads a cursor written by #to_s: `<ISO 8601 time>|<kind>|<id>`.
63
+ def self.parse(value)
64
+ time, kind, id = value.to_s.split("|", 3)
65
+ raise InvalidCursor unless KINDS.key?(kind) && id.to_s.match?(/\A\d+\z/)
66
+
67
+ new(Time.iso8601(time.to_s), kind, id.to_i)
68
+ rescue ArgumentError
69
+ raise InvalidCursor, "Unknown cursor"
70
+ end
71
+
72
+ def to_s
73
+ "#{time.utc.iso8601(6)}|#{kind}|#{id}"
74
+ end
75
+ end
76
+
77
+ # +agents+ and +recordings+ are what the caller may see
78
+ # (Api::BaseController#owner_agents and #reachable_recordings). +before+
79
+ # is the `next_before` of the previous page.
80
+ def initialize(agents:, recordings:, filters: Filters.new, per_page: DEFAULT_PER_PAGE, before: nil)
81
+ @all_agents = agents
82
+ @agents = filters.agent_id ? agents.where(id: filters.agent_id) : agents
83
+ @recordings = recordings
84
+ @filters = filters
85
+ @per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
86
+ @before = before.present? ? Cursor.parse(before) : nil
87
+ @relations = {}
88
+ end
89
+
90
+ # The page as the JSON API returns it: `sessions`, `has_more`,
91
+ # `next_before` (the cursor for the next page, nil on the last) and
92
+ # `total` (every session the filters match).
93
+ # @return [Hash]
94
+ def as_json(*)
95
+ rows = kinds.flat_map { |kind| page_rows(kind) }.sort_by { |row| sort_key(row) }
96
+ page = rows.first(@per_page)
97
+ more = rows.size > @per_page
98
+
99
+ {
100
+ sessions: SessionSummarySerializer.new(page, agents: @all_agents, recordings: @recordings,
101
+ failed_context_ids: failed_context_ids(page)).as_json,
102
+ has_more: more,
103
+ next_before: more ? Cursor.new(page.last[:time], page.last[:kind], page.last[:record].id).to_s : nil,
104
+ total: kinds.sum { |kind| relation(kind).count }
105
+ }
106
+ end
107
+
108
+ private
109
+
110
+ def kinds
111
+ KINDS.keys.select { |kind| @filters.source.nil? || KINDS[kind] == @filters.source }
112
+ end
113
+
114
+ # Newest first; at the same time, in KINDS order, then the newest id.
115
+ def sort_key(row)
116
+ [ -row[:time].to_r, KINDS.keys.index(row[:kind]), -row[:record].id ]
117
+ end
118
+
119
+ # The first per_page + 1 sessions of +kind+ after the cursor: enough to
120
+ # fill the page and to tell whether another follows.
121
+ def page_rows(kind)
122
+ column = TIME_COLUMNS.fetch(kind)
123
+ scope = relation(kind)
124
+ table = scope.klass.arel_table
125
+ after_cursor(scope, kind, table, column)
126
+ .order(table[column].desc, table[:id].desc)
127
+ .limit(@per_page + 1)
128
+ .map { |record| { kind: kind, record: record, time: record.public_send(column) } }
129
+ end
130
+
131
+ def after_cursor(scope, kind, table, column)
132
+ return scope unless @before
133
+
134
+ time = table[column]
135
+ condition =
136
+ case KINDS.keys.index(kind) <=> KINDS.keys.index(@before.kind)
137
+ when 1 then time.lteq(@before.time)
138
+ when 0 then time.lt(@before.time).or(time.eq(@before.time).and(table[:id].lt(@before.id)))
139
+ else time.lt(@before.time)
140
+ end
141
+ scope.where(condition)
142
+ end
143
+
144
+ def relation(kind)
145
+ @relations[kind] ||= within_dates(
146
+ case kind
147
+ when "context" then contexts
148
+ when "scenario_result" then scenario_results
149
+ else recordings
150
+ end,
151
+ TIME_COLUMNS.fetch(kind)
152
+ )
153
+ end
154
+
155
+ def within_dates(scope, column)
156
+ table = scope.klass.arel_table
157
+ scope = scope.where(table[column].gteq(@filters.from)) if @filters.from
158
+ scope = scope.where(table[column].lt(@filters.to)) if @filters.to
159
+ scope
160
+ end
161
+
162
+ # --- conversations --------------------------------------------------------
163
+
164
+ def contexts
165
+ base = AgentContext.for_agents(@agents)
166
+ generations = AgentGeneration.where(agent_context_id: base.select(:id))
167
+ dashboard_generations = generations.where(trace_id: nil).or(generations.where.not(trace_id: replay_traces))
168
+ scope = base.where(id: dashboard_generations.select(:agent_context_id))
169
+ .or(base.where.not(id: generations.select(:agent_context_id)))
170
+
171
+ scope = with_runs(scope, AgentRun.where(agent: @all_agents).on_behalf_of(@filters.actor)) if @filters.actor
172
+ case @filters.outcome
173
+ when "failed" then with_runs(scope, failed_runs)
174
+ when "passed" then scope.none
175
+ else scope
176
+ end
177
+ end
178
+
179
+ # The conversations in +scope+ that +runs+ wrote a generation to or were
180
+ # pinned to.
181
+ def with_runs(scope, runs)
182
+ by_generation = AgentGeneration.where(trace_id: runs.select(:trace_id)).select(:agent_context_id)
183
+ scope.where(id: by_generation).or(scope.where(id: pinned_context_ids(runs)))
184
+ end
185
+
186
+ def pinned_context_ids(runs)
187
+ sql = AgentRun.input_param_sql("context_id")
188
+ runs.where("#{sql} IS NOT NULL").distinct.pluck(Arel.sql(sql))
189
+ .filter_map { |value| Integer(value.to_s, exception: false) }
190
+ end
191
+
192
+ def failed_runs
193
+ AgentRun.where(agent: @all_agents, status: :failed)
194
+ end
195
+
196
+ # Which conversations on +page+ had a failed run.
197
+ def failed_context_ids(page)
198
+ ids = page.select { |row| row[:kind] == "context" }.map { |row| row[:record].id }
199
+ return [] if ids.empty?
200
+
201
+ with_runs(AgentContext.where(id: ids), failed_runs).pluck(:id)
202
+ end
203
+
204
+ # --- evaluation replays ---------------------------------------------------
205
+
206
+ def scenario_results
207
+ evaluations = Evaluation.where(agent: @agents).select(:id)
208
+ scope = EvaluationScenarioResult
209
+ .where(evaluation_run_id: EvaluationRun.where(evaluation_id: evaluations).select(:id))
210
+ .where.not(agent_run_id: nil)
211
+
212
+ scope = scope.where(agent_run_id: AgentRun.on_behalf_of(@filters.actor).select(:id)) if @filters.actor
213
+ case @filters.outcome
214
+ when "failed" then scope.where(status: %i[failed errored])
215
+ when "passed" then scope.where(status: :passed)
216
+ else scope
217
+ end
218
+ end
219
+
220
+ def replay_run_ids
221
+ evaluations = Evaluation.where(agent: @all_agents).select(:id)
222
+ EvaluationScenarioResult
223
+ .where(evaluation_run_id: EvaluationRun.where(evaluation_id: evaluations).select(:id))
224
+ .where.not(agent_run_id: nil)
225
+ .select(:agent_run_id)
226
+ end
227
+
228
+ def replay_traces
229
+ AgentRun.where(id: replay_run_ids).where.not(trace_id: nil).select(:trace_id)
230
+ end
231
+
232
+ # --- browser recordings ---------------------------------------------------
233
+
234
+ def recordings
235
+ base = @recordings.where(agent_context_id: nil)
236
+ scope = base.where(agent_run_id: nil)
237
+ .or(base.where.not(agent_run_id: replay_run_ids).where.not(agent_run_id: conversation_run_ids))
238
+ scope = scope.where(agent_run_id: AgentRun.where(agent: @agents).select(:id)) if @filters.agent_id
239
+ scope = recordings_by_actor(scope) if @filters.actor
240
+
241
+ case @filters.outcome
242
+ when "failed" then scope.where(status: :failed).or(scope.where(agent_run_id: failed_runs.select(:id)))
243
+ when "passed" then scope.none
244
+ else scope
245
+ end
246
+ end
247
+
248
+ # The runs that wrote a generation to one of the caller's conversations.
249
+ # SessionTimeline.for_context replays their recordings with the
250
+ # conversation.
251
+ def conversation_run_ids
252
+ generations = AgentGeneration.where(agent_context_id: AgentContext.for_agents(@all_agents).select(:id))
253
+ AgentRun.where(agent: @all_agents, trace_id: generations.select(:trace_id)).select(:id)
254
+ end
255
+
256
+ # A recording's runs were executed on behalf of the actor, or the user
257
+ # made it.
258
+ def recordings_by_actor(scope)
259
+ by_run = scope.where(agent_run_id: AgentRun.on_behalf_of(@filters.actor).select(:id))
260
+ return by_run unless own_recording_column?
261
+
262
+ by_run.or(scope.where(user_id: @filters.user.id))
263
+ end
264
+
265
+ def own_recording_column?
266
+ user_class = ActionAgent.user_class.to_s
267
+ user_class.present? && @filters.user.class.name == user_class && SessionRecording.column_names.include?("user_id")
268
+ end
269
+ end
270
+ end
@@ -154,6 +154,17 @@ module ActionAgent
154
154
  )
155
155
  end
156
156
 
157
+ # Record the moment the agent stops for a person: what the page asked
158
+ # for, where it stopped, and what the person should do. Pairs with
159
+ # #capture_handoff_state, which stores the page to resume on.
160
+ def handoff(reason:, url:, instructions: nil)
161
+ @recording.record_action!(
162
+ action_type: "handoff",
163
+ value: reason,
164
+ metadata: { url: url, instructions: instructions }.compact
165
+ )
166
+ end
167
+
157
168
  # Capture browser state for handoff
158
169
  def capture_handoff_state(url:, cookies: nil, local_storage: nil, session_storage: nil, form_values: nil)
159
170
  @recording.update!(