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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/interaction_feedback.rb +24 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +155 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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!(
|