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
@@ -0,0 +1,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to run the engine's explorer agent for one Exploration: an AgentRun
5
+ # of the project's explorer agent (Project#explorer_agent!) that walks the
6
+ # project's app in its sandbox browser and proposes the questions a user
7
+ # of the app would ask the project's target agent.
8
+ #
9
+ # The run is an ordinary one, so it is traced, its tool calls are logged
10
+ # as events and its browser actions are recorded on the browser's session
11
+ # recording. What differs is what the model is offered:
12
+ #
13
+ # browser tools BROWSER_TOOLS of the sandbox's browser (its MCP
14
+ # entry), without their file-writing arguments, and
15
+ # navigation only within the app
16
+ # sign_in fills the app's login form from a sign_in
17
+ # ProjectSecret named by secret_ref, in the Rails
18
+ # process (BrowserSignIn): the model never sees the
19
+ # credentials
20
+ # read_last_email the newest mail the app sent an address
21
+ # (SandboxMail)
22
+ # propose_candidate stores a candidate with Exploration#add_candidates!,
23
+ # with the provenance filled in here: the pages and
24
+ # steps since the last candidate, and their range of
25
+ # the recording
26
+ # finish ends the walk
27
+ #
28
+ # Every browser result is scrubbed of the project's secrets before the
29
+ # model, the trace or the run's log sees it, and cut to RESULT_TEXT_LIMIT.
30
+ #
31
+ # The exploration's budget is checked after every tool call. Once its
32
+ # minutes, steps or cost are used up, the results the model was answered
33
+ # with reach CONVERSATION_LIMIT, or someone chose Stop and review, every
34
+ # tool but finish answers that the walk is over, and a model that keeps
35
+ # calling tools is cut short with WalkCutShort.
36
+ class ExplorerExecutionService < AgentExecutionService
37
+ # Raised out of the generation when the model keeps calling tools after
38
+ # the walk ended. #reason is why it ended.
39
+ class WalkCutShort < StandardError
40
+ attr_reader :reason
41
+
42
+ def initialize(reason)
43
+ @reason = reason
44
+ super("The explorer kept calling tools after the exploration ended (#{reason})")
45
+ end
46
+ end
47
+
48
+ # The network tools are left out: a page's request list keeps the
49
+ # sign-in form's POST body, password included, until the next
50
+ # navigation.
51
+ BROWSER_TOOLS = %w[
52
+ browser_navigate browser_navigate_back browser_snapshot browser_find browser_click browser_type browser_fill_form
53
+ browser_select_option browser_press_key browser_hover browser_wait_for browser_tabs browser_handle_dialog
54
+ browser_take_screenshot browser_console_messages browser_generate_locator
55
+ ].freeze
56
+ BROWSER_TOOL_PREFIX = "browser_verify_"
57
+ # Arguments that make a browser tool write its output to a file in the
58
+ # sidecar instead of answering with it.
59
+ FILE_ARGUMENTS = %w[filename].freeze
60
+ # The tools that open a URL, and the argument holding it.
61
+ NAVIGATION_TOOLS = { "browser_navigate" => :url, "browser_tabs" => :url }.freeze
62
+ # Calls a model may make after the walk ended before it is cut short.
63
+ CALLS_AFTER_END = 5
64
+ # Tool turns the provider allows beyond the step budget, for the
65
+ # explorer's own tools.
66
+ TOOL_TURN_MARGIN = 60
67
+ ROSTER_DESCRIPTION_LIMIT = 200
68
+ ROSTER_TOOL_LIMIT = 100
69
+ # Characters of a browser result the model sees; the rest of a long
70
+ # page snapshot is cut.
71
+ RESULT_TEXT_LIMIT = 24_000
72
+ # Characters of tool results, in all, after which the walk ends. Every
73
+ # result stays in the conversation, so this keeps it within a model's
74
+ # context window (about 100,000 tokens), and the walk ends in review
75
+ # rather than failing on an overlong request.
76
+ CONVERSATION_LIMIT = 400_000
77
+
78
+ END_MESSAGES = {
79
+ "finished" => "You called finish.",
80
+ "budget_minutes" => "The exploration's time budget is used up.",
81
+ "budget_steps" => "The exploration's browser step budget is used up.",
82
+ "budget_cost" => "The exploration's cost budget is used up.",
83
+ "budget_context" => "The exploration's conversation is full.",
84
+ "stopped" => "The exploration was stopped for review."
85
+ }.freeze
86
+
87
+ TOOL_DEFINITIONS = [
88
+ {
89
+ name: "sign_in",
90
+ description: "Signs the browser in to the app with saved credentials. You never see or type them: name the " \
91
+ "credentials with secret_ref. Answers whether the browser left the login page; \"unsupported\" " \
92
+ "means the app has no password sign-in the sandbox can use.",
93
+ parameters: {
94
+ type: "object",
95
+ properties: { secret_ref: { type: "string", description: "The name of the saved sign-in to use" } },
96
+ required: [ "secret_ref" ]
97
+ }
98
+ },
99
+ {
100
+ name: "read_last_email",
101
+ description: "Reads the newest email the app sent to an address, such as a sign-up's verification email: its " \
102
+ "subject, sender, text and links. Open a link by navigating to its path. Empty when none was sent.",
103
+ parameters: {
104
+ type: "object",
105
+ properties: { to: { type: "string", description: "The recipient's email address" } },
106
+ required: [ "to" ]
107
+ }
108
+ },
109
+ {
110
+ name: "propose_candidate",
111
+ description: "Proposes one evaluation scenario: a question a user of the page you just explored would ask " \
112
+ "the assistant, in the user's own words, with a rubric for a good answer. Never put steps, " \
113
+ "selectors or URLs in it; where you found it is recorded for you.",
114
+ parameters: {
115
+ type: "object",
116
+ properties: {
117
+ prompt: { type: "string", description: "The user's question" },
118
+ group: { type: "string", description: "The part of the app it is about, such as Orders" },
119
+ rubric: { type: "string", description: "What a good answer says or does, for the judge" },
120
+ tools: { type: "array", items: { type: "string" }, description: "The assistant's tools a good answer needs" },
121
+ contains: { type: "array", items: { type: "string" }, description: "Text a good answer always contains" },
122
+ not_contains: { type: "array", items: { type: "string" }, description: "Text a good answer never contains" }
123
+ },
124
+ required: [ "prompt", "rubric" ]
125
+ }
126
+ },
127
+ {
128
+ name: "finish",
129
+ description: "Ends the exploration. Call it once you have explored the app, or when told the budget is used up.",
130
+ parameters: {
131
+ type: "object",
132
+ properties: { summary: { type: "string", description: "What you explored, in a sentence or two" } },
133
+ required: []
134
+ }
135
+ }
136
+ ].freeze
137
+
138
+ # Scrubs every result its tools answer with of +secrets+ (a callable
139
+ # returning the values), before the run records or returns it.
140
+ class ScrubbingDispatcher < MCPToolDispatcher
141
+ def initialize(agent, extra_server_keys:, secrets:)
142
+ super(agent, extra_server_keys: extra_server_keys)
143
+ @secrets = secrets
144
+ end
145
+
146
+ def call(tool_name, arguments = {})
147
+ result = super
148
+ result && SecretScrubber.scrub(result, @secrets.call)
149
+ end
150
+ end
151
+
152
+ # @param exploration [Exploration] a project's explorer exploration,
153
+ # with its sandbox set
154
+ # @param run [AgentRun] its run, of the project's explorer agent
155
+ def initialize(exploration, run)
156
+ super(run.agent, run)
157
+ @exploration = exploration
158
+ @project = exploration.project
159
+ @sandbox = exploration.sandbox_session
160
+ @steps = 0
161
+ @cost = 0.0
162
+ @answered = 0
163
+ @calls_after_end = 0
164
+ @ended = nil
165
+ @summary = nil
166
+ @walk_started = monotonic
167
+ start_segment
168
+ end
169
+
170
+ # Opens the project's start URL in the browser and runs the explorer.
171
+ # Returns AgentExecutionService#call's result with :stop_reason, why the
172
+ # walk ended, and :summary, what the explorer said it explored.
173
+ #
174
+ # @raise [WalkCutShort]
175
+ # @return [Hash]
176
+ def walk!
177
+ subscription = ActiveSupport::Notifications.subscribe("prompt.provider.active_agent") do |*, payload|
178
+ meter_cost(payload)
179
+ end
180
+ open_start_url
181
+ result = call
182
+ result.merge(stop_reason: @ended || "finished", summary: @summary)
183
+ ensure
184
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
185
+ end
186
+
187
+ # Runs a tool as AgentExecutionService does, then checks the budget. A
188
+ # model that keeps calling tools after the walk ended is cut short.
189
+ def execute_tool(name, **kwargs)
190
+ result = super
191
+ @answered += result.to_json.length
192
+ check_budget!
193
+ raise WalkCutShort, @ended if @ended && @calls_after_end > CALLS_AFTER_END
194
+
195
+ result
196
+ end
197
+
198
+ # The instructions the explorer runs under: how to walk the app, and
199
+ # the target agent's tools, which candidates are checked against.
200
+ def composed_instructions
201
+ @composed_instructions ||= <<~TEXT
202
+ You explore #{@project.repository}, a web application running in a sandbox, through its browser. You find
203
+ the questions a user of the app would ask #{target_agent_name}, an AI assistant for the app, and propose
204
+ each one with propose_candidate.
205
+
206
+ #{target_tools_text}
207
+
208
+ Work in a loop:
209
+ 1. Take a snapshot of the page (browser_snapshot).
210
+ 2. Choose a part of the app you have not explored yet: a page, a list, a filter, a detail view or a form.
211
+ 3. Open it with the browser tools. Navigate with paths on the app, such as /orders.
212
+ 4. Note what the page shows a user and lets them do, then propose the questions a user of that page would
213
+ ask the assistant. Give each a rubric that says what a good answer contains, and the assistant's tools a
214
+ good answer needs.
215
+
216
+ Candidates are questions for the assistant, not click scripts: never write steps, selectors or URLs into a
217
+ prompt or rubric. Where you found each candidate is recorded for you. Prefer questions the assistant's tools
218
+ can answer, and propose a question its tools cannot answer only when a user would clearly ask it.
219
+
220
+ #{sign_in_text}
221
+
222
+ When the app sends an email, such as a verification link, read it with read_last_email and open the link's
223
+ path. If you are stuck, for example on a CAPTCHA, call finish and say so.
224
+
225
+ Your budget is #{budget['steps']} browser steps and #{budget['minutes']} minutes. When a tool answers that the
226
+ exploration has ended, call finish. Call finish with a short summary once you have explored the app.
227
+ TEXT
228
+ end
229
+
230
+ private
231
+
232
+ def generation_options
233
+ { max_tool_turns: budget["steps"].to_i + TOOL_TURN_MARGIN }
234
+ end
235
+
236
+ def tool_schema_halves
237
+ @tool_schema_halves ||= [ browser_tool_definitions, TOOL_DEFINITIONS ]
238
+ end
239
+
240
+ def browser_tool_definitions
241
+ mcp_dispatcher.tool_definitions.filter_map do |definition|
242
+ next unless browser_tool?(definition[:name])
243
+
244
+ parameters = definition[:parameters].deep_dup
245
+ properties = parameters[:properties] || parameters["properties"]
246
+ FILE_ARGUMENTS.each { |argument| properties&.delete(argument) || properties&.delete(argument.to_sym) }
247
+ definition.merge(parameters: parameters)
248
+ end
249
+ end
250
+
251
+ def mcp_dispatcher
252
+ @mcp_dispatcher ||= ScrubbingDispatcher.new(@agent_record, extra_server_keys: [ @sandbox.browser_server_key ],
253
+ secrets: -> { explorer_secrets })
254
+ end
255
+
256
+ def browser_recorder
257
+ @browser_recorder ||= MCPRecordingMiddleware.new(session_recording: @exploration.session_recording, agent_run: @run,
258
+ secrets: -> { recording_secrets })
259
+ end
260
+
261
+ def recording_secrets
262
+ super + explorer_secrets
263
+ end
264
+
265
+ # The project's secrets with their encodings and the sandbox's tokens.
266
+ def explorer_secrets
267
+ @explorer_secrets ||= [ *@project.scrub_values, @sandbox.runtime_mcp_token, @sandbox.browser_token ].compact
268
+ end
269
+
270
+ # The optional third argument matches the dashboard input-request dispatch,
271
+ # which passes the tool call's id; the explorer has no use for it.
272
+ def dispatch_tool(name, kwargs, _tool_call_id = nil)
273
+ name = name.to_s
274
+ if @ended && name != "finish"
275
+ @calls_after_end += 1
276
+ return { error: "#{END_MESSAGES.fetch(@ended)} Call finish now." }
277
+ end
278
+
279
+ case name
280
+ when "sign_in" then sign_in(kwargs[:secret_ref])
281
+ when "read_last_email" then read_last_email(kwargs[:to])
282
+ when "propose_candidate" then propose_candidate(kwargs)
283
+ when "finish" then finish(kwargs[:summary])
284
+ else
285
+ refusal = browser_refusal(name, kwargs)
286
+ return refusal if refusal
287
+
288
+ @steps += 1
289
+ note_step(name, kwargs)
290
+ result = browser_call(name, kwargs.except(*FILE_ARGUMENTS.map(&:to_sym)))
291
+ note_page(result)
292
+ cut(result)
293
+ end
294
+ end
295
+
296
+ def cut(result)
297
+ text = result.is_a?(Hash) ? result[:text] : nil
298
+ return result unless text.is_a?(String) && text.length > RESULT_TEXT_LIMIT
299
+
300
+ result.merge(text: "#{text[0, RESULT_TEXT_LIMIT]}\n# … (cut, #{text.length} characters in all)")
301
+ end
302
+
303
+ # Calls browser tool +name+ on the sandbox's browser, recorded on the
304
+ # recording. A tool the browser does not serve is an error: the
305
+ # explorer never falls back to the toolbox's browser.
306
+ def browser_call(name, kwargs)
307
+ browser_recorder.intercept(tool_name: name, parameters: kwargs) do
308
+ mcp_dispatcher.call(name, kwargs) || { error: "#{name} is not one of the sandbox browser's tools" }
309
+ end
310
+ end
311
+
312
+ def browser_tool?(name)
313
+ BROWSER_TOOLS.include?(name.to_s) || name.to_s.start_with?(BROWSER_TOOL_PREFIX)
314
+ end
315
+
316
+ # Why the explorer may not make browser call +name+, or nil.
317
+ def browser_refusal(name, kwargs)
318
+ return { error: "#{name} is not one of the explorer's tools" } unless browser_tool?(name)
319
+
320
+ navigation_refusal(name, kwargs)
321
+ end
322
+
323
+ # The browser may only open the app: a path, or a URL on the app's
324
+ # origin. The sidecar enforces the same pin.
325
+ def navigation_refusal(name, kwargs)
326
+ argument = NAVIGATION_TOOLS[name]
327
+ return nil unless argument
328
+
329
+ url = kwargs[argument].to_s
330
+ return nil if url.empty? || app_url?(url)
331
+
332
+ { error: "The explorer stays on the app: open a path on it, such as /orders" }
333
+ end
334
+
335
+ def app_url?(url)
336
+ return url.start_with?("/") && !url.start_with?("//") unless url.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
337
+
338
+ app = URI.parse(@sandbox.cloud_run_url.to_s)
339
+ target = URI.parse(url)
340
+ [ target.scheme, target.host, target.port ] == [ app.scheme, app.host, app.port ]
341
+ rescue URI::Error
342
+ false
343
+ end
344
+
345
+ def sign_in(secret_ref)
346
+ @steps += 1
347
+ name = secret_ref.to_s
348
+ secret = @project.secrets.sign_in.find_by(name: name)
349
+ unless secret
350
+ return { error: "No saved sign-in is named #{name.truncate(60)}", available: @project.sign_in_secret_names }
351
+ end
352
+
353
+ note_step("sign_in", element: name)
354
+ result = BrowserSignIn.call(@sandbox, secret).to_h
355
+ note_page({ text: "- Page URL: #{current_page_url}" }) if result[:signed_in]
356
+ result
357
+ end
358
+
359
+ def current_page_url
360
+ SandboxBrowserDriver.new(@sandbox).current_path
361
+ rescue SandboxBrowserDriver::Error
362
+ nil
363
+ end
364
+
365
+ def read_last_email(to)
366
+ message = SandboxMail.last_message(@sandbox, to: to, secrets: explorer_secrets)
367
+ message.empty? ? { email: nil, note: "No email was sent to #{to.to_s.truncate(100)} yet" } : { email: message }
368
+ rescue ArgumentError, SandboxMail::Unsupported, SandboxOrchestrator::UnsupportedBackendError => e
369
+ { error: e.message }
370
+ end
371
+
372
+ def propose_candidate(kwargs)
373
+ raw = kwargs.to_h.stringify_keys.slice("prompt", "group", "rubric", "tools", "contains", "not_contains")
374
+ stored = @exploration.add_candidates!([ raw.merge("provenance" => segment_provenance) ], roster: roster).first
375
+ start_segment
376
+ { proposed: true, id: stored["id"], verdict: stored["verdict"], missing_tools: stored["missing_tools"] }
377
+ rescue Exploration::InvalidCandidate, Exploration::CandidateLimitExceeded => e
378
+ { error: e.message }
379
+ end
380
+
381
+ def finish(summary)
382
+ @ended ||= "finished"
383
+ @summary = summary.to_s.truncate(2_000).presence
384
+ { finished: true }
385
+ end
386
+
387
+ # The target agent's tools, read once, with the project's sandbox and
388
+ # its browser attached: candidates are checked against them, and the
389
+ # instructions list them. Nil when they cannot be read.
390
+ def target_roster
391
+ return @target_roster if defined?(@target_roster)
392
+
393
+ agent = @exploration.target_agent
394
+ extra = [ @sandbox.runtime_server_key, (@sandbox.browser_server_key if @sandbox.browser_running?) ].compact
395
+ @target_roster = agent && RuntimeToolRoster.new(agent, extra_server_keys: extra).tap(&:tools)
396
+ rescue StandardError => e
397
+ Rails.logger.warn("[ActionAgent] exploration #{@exploration.id}: could not list the target agent's tools: #{e.class}")
398
+ @target_roster = nil
399
+ end
400
+
401
+ # The roster as Exploration.verdict reads it.
402
+ def roster
403
+ target_roster && { names: target_roster.tools.keys.to_set, complete: target_roster.discovery_errors.empty? }
404
+ end
405
+
406
+ def target_tools
407
+ target_roster&.tools
408
+ end
409
+
410
+ def target_agent_name
411
+ @exploration.target_agent&.name || "the app's assistant"
412
+ end
413
+
414
+ def target_tools_text
415
+ tools = target_tools
416
+ return "The assistant's tools could not be read, so propose the questions a user would ask about each page." if tools.blank?
417
+
418
+ lines = tools.first(ROSTER_TOOL_LIMIT).map do |name, description|
419
+ "- #{name}: #{description.to_s.squish.truncate(ROSTER_DESCRIPTION_LIMIT)}"
420
+ end
421
+ "The assistant can call these tools:\n#{lines.join("\n")}"
422
+ end
423
+
424
+ def sign_in_text
425
+ names = @project.sign_in_secret_names
426
+ if names.any?
427
+ "If the app asks you to sign in, call sign_in with secret_ref #{names.map { |name| "\"#{name}\"" }.join(' or ')}. " \
428
+ "Never type a password yourself."
429
+ elsif @project.saved_storage_state
430
+ "The browser starts signed in. Never type a password yourself."
431
+ else
432
+ "No sign-in is set up for this app, so explore what is reachable without one. Never type a password yourself."
433
+ end
434
+ end
435
+
436
+ def budget
437
+ @budget ||= Exploration::DEFAULT_BUDGET.merge(@exploration.budget.slice(*Exploration::BUDGET_KEYS))
438
+ end
439
+
440
+ def open_start_url
441
+ SandboxBrowserDriver.new(@sandbox).open(@project.start_url.presence || "/")
442
+ note_page({ text: "- Page URL: #{@project.start_url.presence || '/'}" })
443
+ rescue SandboxBrowserDriver::Error => e
444
+ Rails.logger.warn("[ActionAgent] exploration #{@exploration.id}: could not open the start URL: #{e.message}")
445
+ end
446
+
447
+ def check_budget!
448
+ minutes = (monotonic - @walk_started) / 60.0
449
+ @exploration.record_usage!(minutes: minutes, steps: @steps, cost: @cost)
450
+ @ended ||= "stopped" unless @exploration.walking?
451
+ @ended ||= "budget_steps" if @steps >= budget["steps"].to_i
452
+ @ended ||= "budget_minutes" if minutes >= budget["minutes"].to_f
453
+ @ended ||= "budget_cost" if budget["cost"].present? && @cost >= budget["cost"].to_f
454
+ @ended ||= "budget_context" if @answered >= CONVERSATION_LIMIT
455
+ end
456
+
457
+ def meter_cost(payload)
458
+ return unless payload.is_a?(Hash) && payload[:trace_id] == @run.trace_id
459
+
460
+ usage = payload[:usage] || {}
461
+ @cost += ModelPricing.estimate(model: payload[:model].presence || model, provider: requested_provider,
462
+ input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]).to_f
463
+ rescue StandardError => e
464
+ Rails.logger.warn("[ActionAgent] exploration #{@exploration.id}: could not price a generation: #{e.class}")
465
+ end
466
+
467
+ # --- provenance -----------------------------------------------------
468
+
469
+ def start_segment
470
+ @segment = { started_at: Time.current, steps: [], urls: [] }
471
+ end
472
+
473
+ def note_step(name, kwargs)
474
+ target = kwargs[:element] || kwargs[:url] || kwargs[:key]
475
+ step = [ name.to_s.delete_prefix("browser_").tr("_", " "), target.to_s.squish.truncate(120).presence ].compact.join(": ")
476
+ @segment[:steps] = (@segment[:steps] + [ step ]).last(Exploration::MAX_ITEMS)
477
+ end
478
+
479
+ def note_page(result)
480
+ text = result.is_a?(Hash) ? (result[:text] || result["text"]).to_s : ""
481
+ url = text[SandboxBrowserDriver::PAGE_URL, 1]
482
+ return if url.blank?
483
+
484
+ path = app_path(url)
485
+ @segment[:urls] = (@segment[:urls] | [ path ]).last(Exploration::MAX_ITEMS)
486
+ end
487
+
488
+ # +url+ as a path on the app, or as given when it is not one.
489
+ def app_path(url)
490
+ return url if url.start_with?("/")
491
+
492
+ uri = URI.parse(url)
493
+ app_url?(url) ? [ uri.path.presence || "/", uri.query ].compact.join("?") : url
494
+ rescue URI::Error
495
+ url
496
+ end
497
+
498
+ def segment_provenance
499
+ recording = @exploration.session_recording
500
+ provenance = { "urls" => @segment[:urls], "steps" => @segment[:steps] }
501
+ return provenance unless recording
502
+
503
+ origin = recording.created_at
504
+ provenance.merge(
505
+ "recording_id" => recording.id,
506
+ "range" => {
507
+ "from_ms" => [ ((@segment[:started_at] - origin) * 1000).floor, 0 ].max,
508
+ "to_ms" => [ ((Time.current - origin) * 1000).ceil, 0 ].max
509
+ }
510
+ )
511
+ end
512
+
513
+ def monotonic
514
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
515
+ end
516
+ end
517
+ end