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.
- 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/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/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 +152 -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/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 +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
|