actionagent 1.8.0 → 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 +71 -12
- 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 +58 -7
- 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 +148 -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 +627 -20
- 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 +39 -10
- 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 +72 -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 +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- 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/model_pricing.rb +214 -34
- 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 +207 -4
- 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 +300 -16
- 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/evaluation_serializer.rb +41 -10
- 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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- 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_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- 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 +1263 -102
- 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 +290 -23
- 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 +121 -17
- 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 +153 -2
- 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 +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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 +100 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module ActionAgent
|
|
7
|
+
# Used to install, find and check the browser sidecar the :local sandbox
|
|
8
|
+
# backend runs: the @activeagents/browser-sidecar npm package, versioned
|
|
9
|
+
# with this engine, and the Chromium it drives. Both live under the local
|
|
10
|
+
# sandbox root:
|
|
11
|
+
#
|
|
12
|
+
# .browser/
|
|
13
|
+
# node_modules/ the package and its dependencies
|
|
14
|
+
# browsers/ Chromium (PLAYWRIGHT_BROWSERS_PATH)
|
|
15
|
+
#
|
|
16
|
+
# ActionAgent.browser_sidecar_path names a checkout of the package to run
|
|
17
|
+
# instead, for working on the sidecar; its version is not checked.
|
|
18
|
+
#
|
|
19
|
+
# Nothing here downloads anything except #install!: a start with the
|
|
20
|
+
# sidecar or Chromium missing is refused with the command that installs
|
|
21
|
+
# them (#refusal).
|
|
22
|
+
class BrowserSidecar
|
|
23
|
+
PACKAGE = "@activeagents/browser-sidecar"
|
|
24
|
+
ENTRYPOINT = "bin/browser-sidecar.mjs"
|
|
25
|
+
MINIMUM_NODE = Gem::Version.new("20")
|
|
26
|
+
INSTALL_COMMAND = "bin/rails action_agent:browser:install"
|
|
27
|
+
CHECK_TIMEOUT = 30
|
|
28
|
+
INSTALL_TIMEOUT = 900
|
|
29
|
+
|
|
30
|
+
class Error < StandardError; end
|
|
31
|
+
|
|
32
|
+
# One thing a browser start needs. +fix+ says how to get it when it is
|
|
33
|
+
# not +ok+.
|
|
34
|
+
Check = Struct.new(:name, :ok, :detail, :fix, keyword_init: true) do
|
|
35
|
+
def message
|
|
36
|
+
ok ? detail : "#{detail}. #{fix}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# @return [Pathname]
|
|
42
|
+
def root
|
|
43
|
+
ActionAgent.local_sandbox_root.join(".browser")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Where Chromium is installed and looked for.
|
|
47
|
+
# @return [Pathname]
|
|
48
|
+
def browsers_path
|
|
49
|
+
root.join("browsers")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The configured checkout, or nil to run the installed package.
|
|
53
|
+
# @return [Pathname, nil]
|
|
54
|
+
def checkout
|
|
55
|
+
path = ActionAgent.browser_sidecar_path.presence
|
|
56
|
+
path && Pathname.new(path.to_s).expand_path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Pathname]
|
|
60
|
+
def package_dir
|
|
61
|
+
checkout || root.join("node_modules", *PACKAGE.split("/"))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The environment the sidecar runs with, on top of a sandbox's
|
|
65
|
+
# sanitized one.
|
|
66
|
+
# @return [Hash{String => String}]
|
|
67
|
+
def environment
|
|
68
|
+
{ "PLAYWRIGHT_BROWSERS_PATH" => browsers_path.to_s }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The command line that runs the sidecar with +args+.
|
|
72
|
+
# @return [Array<String>]
|
|
73
|
+
def command(*args)
|
|
74
|
+
[ ActionAgent.node_command.to_s, package_dir.join(ENTRYPOINT).to_s, *args ]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether a sidecar reporting +version+ may run: the engine's own
|
|
78
|
+
# version, or any version from a configured checkout.
|
|
79
|
+
def acceptable_version?(version)
|
|
80
|
+
checkout.present? || version == ActionAgent::VERSION
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Node, the sidecar and Chromium, each checked. The doctor task prints
|
|
84
|
+
# these; a browser start refuses with the first that is not ok.
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Check>]
|
|
87
|
+
def checks
|
|
88
|
+
node = node_check
|
|
89
|
+
return [ node ] unless node.ok
|
|
90
|
+
|
|
91
|
+
sidecar, report = sidecar_check
|
|
92
|
+
return [ node, sidecar ] unless report
|
|
93
|
+
|
|
94
|
+
[ node, sidecar, chromium_check(report) ]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Why a browser cannot start on this machine, or nil when it can.
|
|
98
|
+
# @return [String, nil]
|
|
99
|
+
def refusal
|
|
100
|
+
checks.find { |check| !check.ok }&.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Installs the sidecar (npm ci in a checkout, else the package at the
|
|
104
|
+
# engine's version under #root) and its Chromium, writing their output
|
|
105
|
+
# to +io+.
|
|
106
|
+
#
|
|
107
|
+
# @raise [Error] when a step fails
|
|
108
|
+
def install!(io = $stdout)
|
|
109
|
+
if checkout
|
|
110
|
+
run!(io, [ ActionAgent.npm_command.to_s, "ci" ], chdir: checkout)
|
|
111
|
+
else
|
|
112
|
+
FileUtils.mkdir_p(root)
|
|
113
|
+
manifest = root.join("package.json")
|
|
114
|
+
manifest.write(JSON.pretty_generate({ "private" => true, "description" => "The dashboard's browser sidecar" })) unless manifest.exist?
|
|
115
|
+
run!(io, [ ActionAgent.npm_command.to_s, "install", "--no-audit", "--no-fund", "--save-exact", "#{PACKAGE}@#{ActionAgent::VERSION}" ],
|
|
116
|
+
chdir: root)
|
|
117
|
+
end
|
|
118
|
+
run!(io, command("install-browser"), chdir: checkout || root)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def node_check
|
|
124
|
+
output, status = capture([ ActionAgent.node_command.to_s, "--version" ])
|
|
125
|
+
version = output.to_s[/\Av?(\d+\.\d+\.\d+)/, 1]
|
|
126
|
+
if status&.success? && version && Gem::Version.new(version) >= MINIMUM_NODE
|
|
127
|
+
return Check.new(name: "node", ok: true, detail: "Node.js #{version}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
Check.new(
|
|
131
|
+
name: "node", ok: false,
|
|
132
|
+
detail: version ? "Node.js #{version} is older than #{MINIMUM_NODE}" : "Node.js was not found (#{ActionAgent.node_command})",
|
|
133
|
+
fix: "Install Node.js #{MINIMUM_NODE} or later, or set ActionAgent.node_command"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The sidecar check, and what its `check` command reported (nil when
|
|
138
|
+
# it could not run).
|
|
139
|
+
def sidecar_check
|
|
140
|
+
unless package_dir.join(ENTRYPOINT).file?
|
|
141
|
+
return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar is not installed (#{package_dir})",
|
|
142
|
+
fix: "Run #{INSTALL_COMMAND}"), nil ]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
output, status = capture(command("check"))
|
|
146
|
+
report = status&.success? ? parse_report(output) : nil
|
|
147
|
+
unless report
|
|
148
|
+
return [ Check.new(name: "sidecar", ok: false, detail: "The browser sidecar did not run: #{output.to_s.strip.lines.last&.strip}",
|
|
149
|
+
fix: "Run #{INSTALL_COMMAND}"), nil ]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
version = report["version"].to_s
|
|
153
|
+
unless acceptable_version?(version)
|
|
154
|
+
return [ Check.new(name: "sidecar", ok: false,
|
|
155
|
+
detail: "The browser sidecar is version #{version}, and this dashboard needs #{ActionAgent::VERSION}",
|
|
156
|
+
fix: "Run #{INSTALL_COMMAND}"), report ]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
[ Check.new(name: "sidecar", ok: true, detail: "Browser sidecar #{version}#{" (#{checkout})" if checkout}"), report ]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def chromium_check(report)
|
|
163
|
+
chromium = report["chromium"].is_a?(Hash) ? report["chromium"] : {}
|
|
164
|
+
return Check.new(name: "chromium", ok: true, detail: "Chromium at #{chromium["executable"]}") if chromium["installed"]
|
|
165
|
+
|
|
166
|
+
Check.new(name: "chromium", ok: false, detail: "Chromium is not installed (#{browsers_path})", fix: "Run #{INSTALL_COMMAND}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def parse_report(output)
|
|
170
|
+
report = JSON.parse(output.to_s.lines.last.to_s)
|
|
171
|
+
report.is_a?(Hash) ? report : nil
|
|
172
|
+
rescue JSON::ParserError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def process_environment
|
|
177
|
+
LocalSandboxBackend.sanitized_environment.merge(environment)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Runs +argv+ with output captured, for at most CHECK_TIMEOUT seconds.
|
|
181
|
+
# Returns the output and the exit status, nil when it could not run.
|
|
182
|
+
def capture(argv)
|
|
183
|
+
Open3.popen2e(process_environment, *argv, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
|
|
184
|
+
stdin.close
|
|
185
|
+
reader = Thread.new { output.read }
|
|
186
|
+
unless waiter.join(CHECK_TIMEOUT)
|
|
187
|
+
Process.kill("KILL", -waiter.pid)
|
|
188
|
+
return [ "timed out after #{CHECK_TIMEOUT}s", nil ]
|
|
189
|
+
end
|
|
190
|
+
[ reader.value, waiter.value ]
|
|
191
|
+
end
|
|
192
|
+
rescue SystemCallError => e
|
|
193
|
+
[ e.message, nil ]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def run!(io, argv, chdir:)
|
|
197
|
+
io.puts("$ #{argv.join(' ')}")
|
|
198
|
+
Open3.popen2e(process_environment, *argv, chdir: chdir.to_s, unsetenv_others: true, pgroup: true) do |stdin, output, waiter|
|
|
199
|
+
stdin.close
|
|
200
|
+
reader = Thread.new { output.each_line { |line| io.write(line) } }
|
|
201
|
+
unless waiter.join(INSTALL_TIMEOUT)
|
|
202
|
+
Process.kill("KILL", -waiter.pid)
|
|
203
|
+
raise Error, "#{argv.first} did not finish within #{INSTALL_TIMEOUT}s"
|
|
204
|
+
end
|
|
205
|
+
reader.join
|
|
206
|
+
raise Error, "#{argv.join(' ')} failed (#{waiter.value.exitstatus || waiter.value.termsig})" unless waiter.value.success?
|
|
207
|
+
end
|
|
208
|
+
rescue SystemCallError => e
|
|
209
|
+
raise Error, "Could not run #{argv.first}: #{e.message}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to sign a project's sandbox browser in to the app with a sign_in
|
|
5
|
+
# ProjectSecret, from the Rails process, so the credentials never reach a
|
|
6
|
+
# model: they are typed into the form by SandboxBrowserDriver and appear in
|
|
7
|
+
# no tool argument, tool result, log line, span or recorded action. The
|
|
8
|
+
# browser's own recording masks what is typed.
|
|
9
|
+
#
|
|
10
|
+
# The form is found on the secret's login_url: the password field, then the
|
|
11
|
+
# login field and the submit button near it, unless the secret names them
|
|
12
|
+
# with CSS selectors. The browser is signed in once the page it shows has
|
|
13
|
+
# no password field. Otherwise the password field is emptied, so a later
|
|
14
|
+
# page snapshot cannot show it.
|
|
15
|
+
class BrowserSignIn
|
|
16
|
+
# The outcome, as the explorer's sign_in tool and the test account step
|
|
17
|
+
# report it:
|
|
18
|
+
#
|
|
19
|
+
# signed_in the browser left the login form
|
|
20
|
+
# failed it still shows a password field, as after a wrong
|
|
21
|
+
# password
|
|
22
|
+
# unsupported the login page has no password field, as with sign-in
|
|
23
|
+
# through another site (OAuth or SSO)
|
|
24
|
+
# error the browser could not be driven
|
|
25
|
+
Result = Struct.new(:status, :message, keyword_init: true) do
|
|
26
|
+
def signed_in?
|
|
27
|
+
status == "signed_in"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ status: status, signed_in: signed_in?, message: message }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
MESSAGES = {
|
|
36
|
+
"signed_in" => "Signed in: the browser left the login form.",
|
|
37
|
+
"failed" => "The browser still shows the login form after submitting it. Check the login and password.",
|
|
38
|
+
"unsupported" => "The login page has no password field, so the app's sign-in (OAuth or SSO only) is not " \
|
|
39
|
+
"supported in the sandbox.",
|
|
40
|
+
"error" => "The sandbox's browser could not be driven to sign in."
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
PASSWORD_TARGET = '[data-aa-sign-in="password"]'
|
|
44
|
+
LOGIN_TARGET = '[data-aa-sign-in="login"]'
|
|
45
|
+
SUBMIT_TARGET = '[data-aa-sign-in="submit"]'
|
|
46
|
+
LOGIN_CANDIDATES = 'input[type="email"], input[autocomplete="username"], input[autocomplete="email"], ' \
|
|
47
|
+
'input[name*="email" i], input[name*="login" i], input[name*="user" i], input[type="text"]'
|
|
48
|
+
# How long a sign-in that has not left the login form yet is given before
|
|
49
|
+
# it counts as failed.
|
|
50
|
+
SETTLE_SECONDS = 2
|
|
51
|
+
VISIBLE = "(el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length))"
|
|
52
|
+
CLEAR_PASSWORDS = "() => { document.querySelectorAll('input[type=\"password\"]').forEach((el) => { el.value = ''; }); return true; }"
|
|
53
|
+
|
|
54
|
+
# @param sandbox [SandboxSession] with its browser running
|
|
55
|
+
# @param secret [ProjectSecret] a sign_in secret
|
|
56
|
+
# @return [Result]
|
|
57
|
+
def self.call(sandbox, secret, driver: nil)
|
|
58
|
+
new(sandbox, secret, driver).call
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(sandbox, secret, driver)
|
|
62
|
+
@sandbox = sandbox
|
|
63
|
+
@secret = secret
|
|
64
|
+
@driver = driver
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call
|
|
68
|
+
credentials = @secret.sign_in_credentials
|
|
69
|
+
return result("failed") if credentials["password"].blank?
|
|
70
|
+
|
|
71
|
+
driver.open(credentials["login_url"])
|
|
72
|
+
marks = driver.evaluate(mark_fields(credentials))
|
|
73
|
+
return result("unsupported") unless marks.is_a?(Hash) && marks["password"] == true
|
|
74
|
+
|
|
75
|
+
login_path = marks["path"]
|
|
76
|
+
driver.type(LOGIN_TARGET, credentials["login"]) if marks["login"] == true && credentials["login"].present?
|
|
77
|
+
if marks["submit"] == true
|
|
78
|
+
driver.type(PASSWORD_TARGET, credentials["password"])
|
|
79
|
+
driver.click(SUBMIT_TARGET)
|
|
80
|
+
else
|
|
81
|
+
driver.type(PASSWORD_TARGET, credentials["password"], submit: true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return result("signed_in") if left_form?(credentials, login_path)
|
|
85
|
+
|
|
86
|
+
driver.evaluate(CLEAR_PASSWORDS)
|
|
87
|
+
result("failed")
|
|
88
|
+
rescue SandboxBrowserDriver::Error => e
|
|
89
|
+
Rails.logger.warn("[ActionAgent] sign-in in sandbox #{@sandbox.session_id} failed: #{e.message}")
|
|
90
|
+
clear_quietly
|
|
91
|
+
result("error")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def driver
|
|
97
|
+
@driver ||= SandboxBrowserDriver.new(@sandbox)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def result(status)
|
|
101
|
+
Result.new(status: status, message: MESSAGES.fetch(status))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether the browser shows a page without a password field: at once on
|
|
105
|
+
# a page other than +login_path+, or on any page after SETTLE_SECONDS,
|
|
106
|
+
# for an app that redirects late or whose signed-in page shares the
|
|
107
|
+
# login page's path. A failure the app renders on another path, such as
|
|
108
|
+
# the form's POST path, still shows the field.
|
|
109
|
+
def left_form?(credentials, login_path)
|
|
110
|
+
state = page_state(credentials)
|
|
111
|
+
return true if state && state["path"] != login_path && !state["passwordField"]
|
|
112
|
+
|
|
113
|
+
driver.wait(SETTLE_SECONDS)
|
|
114
|
+
state = page_state(credentials)
|
|
115
|
+
state.present? && !state["passwordField"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# { "path", "passwordField" }: the page's path, and whether it shows the
|
|
119
|
+
# secret's password field or any visible one. Nil when the page does not
|
|
120
|
+
# answer.
|
|
121
|
+
def page_state(credentials)
|
|
122
|
+
state = driver.evaluate(<<~JS.squish)
|
|
123
|
+
() => {
|
|
124
|
+
const visible = #{VISIBLE};
|
|
125
|
+
let named = null;
|
|
126
|
+
try { named = #{credentials['password_field'].to_json} ? document.querySelector(#{credentials['password_field'].to_json}) : null; } catch (e) {}
|
|
127
|
+
const field = named || [...document.querySelectorAll('input[type="password"]')].find(visible);
|
|
128
|
+
return { path: location.pathname, passwordField: visible(field) };
|
|
129
|
+
}
|
|
130
|
+
JS
|
|
131
|
+
state.is_a?(Hash) ? state : nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def clear_quietly
|
|
135
|
+
@driver&.evaluate(CLEAR_PASSWORDS)
|
|
136
|
+
rescue SandboxBrowserDriver::Error
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The page function that finds the sign-in form and marks its fields
|
|
141
|
+
# with data-aa-sign-in, answering which it found. Selectors from the
|
|
142
|
+
# secret are passed as JSON string literals.
|
|
143
|
+
def mark_fields(credentials)
|
|
144
|
+
password, login, submit = credentials.values_at("password_field", "login_field", "submit_field").map(&:to_json)
|
|
145
|
+
<<~JS.squish
|
|
146
|
+
() => {
|
|
147
|
+
const visible = #{VISIBLE};
|
|
148
|
+
const pick = (selector) => { try { return selector ? document.querySelector(selector) : null; } catch (e) { return null; } };
|
|
149
|
+
document.querySelectorAll('[data-aa-sign-in]').forEach((el) => el.removeAttribute('data-aa-sign-in'));
|
|
150
|
+
const password = pick(#{password}) || [...document.querySelectorAll('input[type="password"]')].find(visible);
|
|
151
|
+
if (!password) return { password: false, path: location.pathname };
|
|
152
|
+
const scope = password.form || document;
|
|
153
|
+
const login = pick(#{login}) || [...scope.querySelectorAll(#{LOGIN_CANDIDATES.to_json})].find((el) => el !== password && visible(el));
|
|
154
|
+
const submit = pick(#{submit}) || scope.querySelector('button[type="submit"], input[type="submit"]') || scope.querySelector('button:not([type])');
|
|
155
|
+
password.setAttribute('data-aa-sign-in', 'password');
|
|
156
|
+
if (login) login.setAttribute('data-aa-sign-in', 'login');
|
|
157
|
+
if (submit) submit.setAttribute('data-aa-sign-in', 'submit');
|
|
158
|
+
return { password: true, login: !!login, submit: !!submit, path: location.pathname };
|
|
159
|
+
}
|
|
160
|
+
JS
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Masks what a browser tool call typed: the `text` of `browser_type` and
|
|
5
|
+
# `browser_press_sequentially`, the `promptText` of `browser_handle_dialog`
|
|
6
|
+
# and each field `value` of `browser_fill_form`. MCPRecordingMiddleware applies it before an agent's
|
|
7
|
+
# browser action is stored, and SessionTimeline before a tool call is
|
|
8
|
+
# shown.
|
|
9
|
+
#
|
|
10
|
+
# Masking is per call. A value one call typed is masked in that call's
|
|
11
|
+
# arguments and result, but not in another call's result that shows it,
|
|
12
|
+
# such as a later snapshot of the filled field.
|
|
13
|
+
module BrowserToolRedaction
|
|
14
|
+
MASK = "[REDACTED]"
|
|
15
|
+
# The argument that holds what each single-value typing tool typed.
|
|
16
|
+
TYPED_ARGUMENTS = {
|
|
17
|
+
"browser_type" => "text", "browser_press_sequentially" => "text", "browser_handle_dialog" => "promptText"
|
|
18
|
+
}.freeze
|
|
19
|
+
TYPING_TOOLS = [ *TYPED_ARGUMENTS.keys, "browser_fill_form" ].freeze
|
|
20
|
+
# Shorter typed values are masked in the arguments but not searched for
|
|
21
|
+
# in other text, where they would mask unrelated characters.
|
|
22
|
+
MIN_SEARCHED_LENGTH = 3
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def typing_tool?(tool_name)
|
|
27
|
+
TYPING_TOOLS.include?(tool_name.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns +arguments+ with the typed values masked, in the form given: a
|
|
31
|
+
# Hash, or JSON text. Arguments of a typing tool that cannot be read as a
|
|
32
|
+
# Hash, such as JSON cut off mid-string, are masked whole. Other tools'
|
|
33
|
+
# arguments are returned unchanged.
|
|
34
|
+
def redact_arguments(tool_name, arguments)
|
|
35
|
+
return arguments unless typing_tool?(tool_name)
|
|
36
|
+
|
|
37
|
+
parsed = parse(arguments)
|
|
38
|
+
return MASK unless parsed.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
typed = TYPED_ARGUMENTS[tool_name.to_s]
|
|
41
|
+
masked =
|
|
42
|
+
if typed
|
|
43
|
+
parsed.key?(typed) ? parsed.merge(typed => MASK) : parsed
|
|
44
|
+
else
|
|
45
|
+
parsed.merge("fields" => Array(parsed["fields"]).map { |field| mask_field(field) })
|
|
46
|
+
end
|
|
47
|
+
arguments.is_a?(String) ? masked.to_json : masked
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns +text+, such as a tool result, with the values the call typed
|
|
51
|
+
# masked. A typing tool's text is masked whole when its +arguments+
|
|
52
|
+
# cannot be read. Other tools' text is returned unchanged.
|
|
53
|
+
def redact_text(tool_name, text, arguments)
|
|
54
|
+
return text unless typing_tool?(tool_name) && text.is_a?(String)
|
|
55
|
+
|
|
56
|
+
parsed = parse(arguments)
|
|
57
|
+
return MASK unless parsed.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
typed_values(tool_name, parsed)
|
|
60
|
+
.select { |value| value.length >= MIN_SEARCHED_LENGTH }
|
|
61
|
+
.sort_by { |value| -value.length }
|
|
62
|
+
.reduce(text) { |masked, value| masked.gsub(value, MASK) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The values a typing tool call typed, from its parsed arguments.
|
|
66
|
+
def typed_values(tool_name, parsed)
|
|
67
|
+
typed = TYPED_ARGUMENTS[tool_name.to_s]
|
|
68
|
+
values = typed ? [ parsed[typed] ] : Array(parsed["fields"]).map { |field| field["value"] if field.is_a?(Hash) }
|
|
69
|
+
values.compact.map(&:to_s).reject(&:empty?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mask_field(field)
|
|
73
|
+
return field unless field.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
field = field.deep_stringify_keys
|
|
76
|
+
field.key?("value") ? field.merge("value" => MASK) : field
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +arguments+ as a string-keyed Hash: a Hash as given, JSON text parsed.
|
|
80
|
+
# Anything else, or JSON that does not parse, is returned as nil.
|
|
81
|
+
def parse(arguments)
|
|
82
|
+
case arguments
|
|
83
|
+
when Hash then arguments.deep_stringify_keys
|
|
84
|
+
when String
|
|
85
|
+
parsed = JSON.parse(arguments)
|
|
86
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
87
|
+
end
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -14,8 +14,8 @@ module ActionAgent
|
|
|
14
14
|
# LocalSandboxBackend.claude_login_status). The dashboard never
|
|
15
15
|
# sees that credential.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# :sandbox_login is the user's login inside the unmodified CLI. Only the
|
|
18
|
+
# CLI holds its credential; the dashboard relays a one-use login code.
|
|
19
19
|
# https://code.claude.com/docs/en/legal-and-compliance.md
|
|
20
20
|
module ClaudeCodeAuth
|
|
21
21
|
module_function
|
|
@@ -29,6 +29,12 @@ module ActionAgent
|
|
|
29
29
|
mode == "local_login"
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def sandbox_login?
|
|
33
|
+
mode == "sandbox_login"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
LOGIN_VERBS = %i[start_claude_login submit_claude_login_code claude_login_status claude_logout].freeze
|
|
37
|
+
|
|
32
38
|
# Why +orchestrator+'s backend cannot run Claude Code sessions with the
|
|
33
39
|
# configured authentication, or nil. A machine's own login is the
|
|
34
40
|
# dashboard user's, so only a backend running sessions as that user, on
|
|
@@ -36,6 +42,15 @@ module ActionAgent
|
|
|
36
42
|
# find no login or need it copied there, which is what this mode exists
|
|
37
43
|
# to never do.
|
|
38
44
|
def backend_refusal(orchestrator)
|
|
45
|
+
if sandbox_login?
|
|
46
|
+
unless LOGIN_VERBS.all? { |verb| orchestrator.supports?(verb) }
|
|
47
|
+
return "The #{orchestrator.backend_name} backend does not implement sandbox Claude login (#{LOGIN_VERBS.join(', ')})"
|
|
48
|
+
end
|
|
49
|
+
unless orchestrator.local? || ActionAgent.claude_code_hosted_login_enabled
|
|
50
|
+
return "Hosted Claude subscription login is disabled; the operator must review the hosting requirements and enable it explicitly"
|
|
51
|
+
end
|
|
52
|
+
return
|
|
53
|
+
end
|
|
39
54
|
return unless local_login?
|
|
40
55
|
return if orchestrator.local?
|
|
41
56
|
|
|
@@ -53,12 +68,15 @@ module ActionAgent
|
|
|
53
68
|
# +provider_keys+: booleans and the login's method, never a credential.
|
|
54
69
|
#
|
|
55
70
|
# @return [Hash] { mode:, connected:, login: } (login for :local_login only)
|
|
56
|
-
def status(provider_keys)
|
|
71
|
+
def status(provider_keys, sandboxes: [], user_id: nil)
|
|
57
72
|
if local_login?
|
|
58
73
|
# Only the :local backend uses the login, and no other one needs
|
|
59
74
|
# this machine's CLI asked about it.
|
|
60
75
|
login = local_backend? ? LocalSandboxBackend.claude_login_status : LocalSandboxBackend::LOGGED_OUT
|
|
61
76
|
{ mode: mode, connected: login[:logged_in], login: login.slice(:logged_in, :auth_method) }
|
|
77
|
+
elsif sandbox_login?
|
|
78
|
+
sessions = Array(sandboxes).map { |sandbox| sandbox_status(sandbox, user_id: user_id) }
|
|
79
|
+
{ mode: mode, connected: api_key_connected?(provider_keys) || sessions.any? { |row| row[:logged_in] }, sandboxes: sessions }
|
|
62
80
|
else
|
|
63
81
|
{ mode: mode, connected: api_key_connected?(provider_keys) }
|
|
64
82
|
end
|
|
@@ -73,7 +91,15 @@ module ActionAgent
|
|
|
73
91
|
|
|
74
92
|
# Why a Claude Code session cannot start in +sandbox+ for want of
|
|
75
93
|
# credentials, or nil.
|
|
76
|
-
def credential_refusal(sandbox)
|
|
94
|
+
def credential_refusal(sandbox, user_id: nil)
|
|
95
|
+
if sandbox_login?
|
|
96
|
+
if (refusal = login_owner_refusal(sandbox, user_id: user_id))
|
|
97
|
+
return refusal
|
|
98
|
+
end
|
|
99
|
+
return if credential_mode(sandbox, user_id: user_id)
|
|
100
|
+
|
|
101
|
+
return "Sign in with your own Claude subscription in this sandbox, or connect an Anthropic API key"
|
|
102
|
+
end
|
|
77
103
|
if local_login?
|
|
78
104
|
return if LocalSandboxBackend.claude_login_status[:logged_in]
|
|
79
105
|
|
|
@@ -82,5 +108,45 @@ module ActionAgent
|
|
|
82
108
|
"Claude Code is not connected: connect an Anthropic API key in Settings -> Integrations first"
|
|
83
109
|
end
|
|
84
110
|
end
|
|
111
|
+
|
|
112
|
+
# Why +user_id+ may run nothing in +sandbox+ under :sandbox_login, or nil.
|
|
113
|
+
# Every session runs as the same OS user beside the signed-in member's
|
|
114
|
+
# CLAUDE_CONFIG_DIR, so while one member's login is there no one else's
|
|
115
|
+
# session, on any runner or credential, may run in that checkout.
|
|
116
|
+
def login_owner_refusal(sandbox, user_id: nil)
|
|
117
|
+
return unless sandbox_login?
|
|
118
|
+
owner = sandbox.try(:claude_login_user_id)
|
|
119
|
+
return if owner.nil? || (user_id.present? && owner == user_id)
|
|
120
|
+
|
|
121
|
+
"Another member is signed in to Claude Code in this sandbox; start your own sandbox"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def credential_mode(sandbox, user_id: nil)
|
|
125
|
+
return mode unless sandbox_login?
|
|
126
|
+
return if login_owner_refusal(sandbox, user_id: user_id)
|
|
127
|
+
if user_id.present? && sandbox.claude_login_user_id == user_id
|
|
128
|
+
login = SandboxOrchestrator.new.claude_login_status(sandbox)
|
|
129
|
+
return "sandbox_login" if login[:logged_in] && login[:auth_method] == "claude.ai"
|
|
130
|
+
end
|
|
131
|
+
"api_key" if sandbox.runtime_environment.present?
|
|
132
|
+
rescue LocalSandboxBackend::Error, SandboxOrchestrator::UnsupportedBackendError, SandboxOrchestrator::BackendError
|
|
133
|
+
"api_key" if sandbox.runtime_environment.present?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sandbox_status(sandbox, user_id: nil)
|
|
137
|
+
mine = user_id.present? && sandbox.claude_login_user_id == user_id
|
|
138
|
+
login = if mine && sandbox.ready? && sandbox.active? && !backend_refusal(SandboxOrchestrator.new)
|
|
139
|
+
SandboxOrchestrator.new.claude_login_status(sandbox).slice(:logged_in, :auth_method)
|
|
140
|
+
else
|
|
141
|
+
{ logged_in: false, auth_method: nil }
|
|
142
|
+
end
|
|
143
|
+
login = { logged_in: false, auth_method: nil } unless login[:logged_in] && login[:auth_method] == "claude.ai"
|
|
144
|
+
fallback = sandbox.runtime_environment.present? && !login_owner_refusal(sandbox, user_id: user_id) ? "api_key" : nil
|
|
145
|
+
login.merge(session_id: sandbox.session_id, owned_by_you: mine,
|
|
146
|
+
credential_mode: login[:logged_in] ? "sandbox_login" : fallback)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
{ session_id: sandbox.session_id, logged_in: false, auth_method: nil, owned_by_you: mine,
|
|
149
|
+
credential_mode: sandbox.runtime_environment.present? && !login_owner_refusal(sandbox, user_id: user_id) ? "api_key" : nil }
|
|
150
|
+
end
|
|
85
151
|
end
|
|
86
152
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Keep Codex's native JSONL transcript while adapting its terminal event
|
|
5
|
+
# to CodeSession's shared result contract. A process exit alone is never
|
|
6
|
+
# enough to mark a coding session successful.
|
|
7
|
+
class CodexSessionEvents
|
|
8
|
+
def consume(event)
|
|
9
|
+
return unless event.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
case event["type"]
|
|
12
|
+
when "thread.started"
|
|
13
|
+
@thread_id = event["thread_id"]
|
|
14
|
+
when "item.completed"
|
|
15
|
+
item = event["item"]
|
|
16
|
+
@answer = item["text"] if item.is_a?(Hash) && item["type"] == "agent_message"
|
|
17
|
+
when "turn.completed"
|
|
18
|
+
return result(false, @answer, event["usage"])
|
|
19
|
+
when "turn.failed", "error"
|
|
20
|
+
error = event["error"]
|
|
21
|
+
message = error.is_a?(Hash) ? error["message"] : event["message"]
|
|
22
|
+
return result(true, message.presence || "Codex reported a failed turn", {})
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def result(failed, answer, usage)
|
|
30
|
+
{
|
|
31
|
+
"type" => "result", "subtype" => failed ? "error_during_execution" : "success",
|
|
32
|
+
"is_error" => failed, "result" => answer, "session_id" => @thread_id,
|
|
33
|
+
"usage" => usage.is_a?(Hash) ? usage.slice("input_tokens", "output_tokens") : {}
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|