actionagent 1.9.2 → 1.9.3
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/README.md +23 -0
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -61
- data/app/controllers/action_agent/api/agents_controller.rb +23 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
- data/app/controllers/action_agent/api/evaluations_controller.rb +41 -3
- data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
- data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
- data/app/jobs/action_agent/code_session_job.rb +19 -2
- data/app/models/action_agent/agent.rb +54 -1
- data/app/models/action_agent/code_session.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +17 -2
- data/app/models/action_agent/provider_key.rb +5 -1
- data/app/queries/action_agent/trace_window_report.rb +60 -0
- data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
- data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
- data/app/services/action_agent/agent_execution_service.rb +37 -0
- data/app/services/action_agent/agent_tool_roster.rb +53 -18
- data/app/services/action_agent/agent_toolbox.rb +156 -3
- data/app/services/action_agent/browser_client_token.rb +86 -0
- data/app/services/action_agent/checkout_tools.rb +154 -0
- data/app/services/action_agent/code_conversation.rb +133 -0
- data/app/services/action_agent/codex_auth.rb +81 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
- data/app/services/action_agent/diff_stat.rb +27 -0
- data/app/services/action_agent/evaluation_report_export.rb +175 -0
- data/app/services/action_agent/evaluation_report_import.rb +40 -17
- data/app/services/action_agent/evaluation_runner_service.rb +48 -12
- data/app/services/action_agent/local_sandbox_backend.rb +295 -30
- data/app/services/action_agent/mcp_catalog.rb +25 -0
- data/app/services/action_agent/mcp_client.rb +1 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
- data/app/services/action_agent/project_boot_fix.rb +10 -3
- data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +1 -0
- data/app/services/action_agent/slack_tools.rb +101 -0
- data/config/routes.rb +10 -1
- data/lib/action_agent/engine.rb +6 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +35 -2
- data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
- data/lib/tasks/evaluations.rake +78 -0
- metadata +13 -2
|
@@ -11,9 +11,11 @@ module ActionAgent
|
|
|
11
11
|
# tool-call roundtrips that show up as :tool spans in Traces, tool messages
|
|
12
12
|
# in Interactions, and tool counts in Metrics.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# Every capability in Agent::AVAILABLE_TOOLS has definitions here. The ones
|
|
15
|
+
# that act for the run — on its checkout (CheckoutTools), its Slack
|
|
16
|
+
# connection (SlackTools), its provider (translate_text), its memory, its
|
|
17
|
+
# workspace's agents, or a person — are routed by AgentExecutionService and
|
|
18
|
+
# have no FUNCTIONS entry.
|
|
17
19
|
class AgentToolbox
|
|
18
20
|
FETCH_LIMIT_BYTES = 50_000
|
|
19
21
|
FETCH_TIMEOUT_SECONDS = 5
|
|
@@ -262,6 +264,138 @@ module ActionAgent
|
|
|
262
264
|
}
|
|
263
265
|
}
|
|
264
266
|
],
|
|
267
|
+
# The checkout capabilities: they act on the checkout sandbox the run
|
|
268
|
+
# was given (CheckoutTools), so AgentExecutionService routes them, and
|
|
269
|
+
# they are NOT in FUNCTIONS below.
|
|
270
|
+
"terminal" => [
|
|
271
|
+
{
|
|
272
|
+
name: "run_command",
|
|
273
|
+
description: "Run a shell command (bash) in the root of the repository checkout this run was given, with the " \
|
|
274
|
+
"app's environment and database, and return its exit status and combined output. Use it to run tests, " \
|
|
275
|
+
"rake tasks, scripts or git. Each call starts a fresh shell.",
|
|
276
|
+
parameters: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
command: { type: "string", description: "The command line, e.g. bin/rails test test/models" },
|
|
280
|
+
timeout_seconds: { type: "integer", description: "Stop the command after this many seconds (at most 120)" }
|
|
281
|
+
},
|
|
282
|
+
required: [ "command" ]
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
],
|
|
286
|
+
"filesystem" => [
|
|
287
|
+
{
|
|
288
|
+
name: "list_files",
|
|
289
|
+
description: "List the files in the repository checkout this run was given, tracked and untracked, without " \
|
|
290
|
+
"the ones .gitignore excludes. Pass a directory to list only what is under it.",
|
|
291
|
+
parameters: {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: { path: { type: "string", description: "A directory relative to the checkout root, e.g. app/models" } },
|
|
294
|
+
required: []
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "read_file",
|
|
299
|
+
description: "Read a text file from the repository checkout this run was given.",
|
|
300
|
+
parameters: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: { path: { type: "string", description: "The file's path relative to the checkout root" } },
|
|
303
|
+
required: [ "path" ]
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "write_file",
|
|
308
|
+
description: "Write a text file in the repository checkout this run was given, replacing it if it exists and " \
|
|
309
|
+
"creating the directories it needs.",
|
|
310
|
+
parameters: {
|
|
311
|
+
type: "object",
|
|
312
|
+
properties: {
|
|
313
|
+
path: { type: "string", description: "The file's path relative to the checkout root" },
|
|
314
|
+
content: { type: "string", description: "The file's complete new content" }
|
|
315
|
+
},
|
|
316
|
+
required: [ "path", "content" ]
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
"edit" => [
|
|
321
|
+
{
|
|
322
|
+
name: "edit_file",
|
|
323
|
+
description: "Edit a file in the repository checkout this run was given by replacing one exact passage. " \
|
|
324
|
+
"old_text must appear exactly once in the file; include enough surrounding lines to make it unique.",
|
|
325
|
+
parameters: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {
|
|
328
|
+
path: { type: "string", description: "The file's path relative to the checkout root" },
|
|
329
|
+
old_text: { type: "string", description: "The exact text to replace" },
|
|
330
|
+
new_text: { type: "string", description: "What to put in its place" }
|
|
331
|
+
},
|
|
332
|
+
required: [ "path", "old_text", "new_text" ]
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
"database" => [
|
|
337
|
+
{
|
|
338
|
+
name: "query_database",
|
|
339
|
+
description: "Run a read-only SQL query against the app's database in the repository checkout this run was " \
|
|
340
|
+
"given, and return the columns and up to 200 rows. Writes are refused.",
|
|
341
|
+
parameters: {
|
|
342
|
+
type: "object",
|
|
343
|
+
properties: { sql: { type: "string", description: "One SQL statement, e.g. SELECT id, email FROM users LIMIT 10" } },
|
|
344
|
+
required: [ "sql" ]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
],
|
|
348
|
+
# Through the owner's Slack bot token (SlackTools), routed by
|
|
349
|
+
# AgentExecutionService.
|
|
350
|
+
"slack" => [
|
|
351
|
+
{
|
|
352
|
+
name: "slack_list_channels",
|
|
353
|
+
description: "List the Slack channels the workspace's bot can see, with their ids.",
|
|
354
|
+
parameters: { type: "object", properties: {}, required: [] }
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: "slack_read_channel",
|
|
358
|
+
description: "Read a Slack channel's most recent messages, newest first.",
|
|
359
|
+
parameters: {
|
|
360
|
+
type: "object",
|
|
361
|
+
properties: {
|
|
362
|
+
channel: { type: "string", description: "The channel id, e.g. C0123456789" },
|
|
363
|
+
limit: { type: "integer", description: "How many messages (default 20, at most 50)" }
|
|
364
|
+
},
|
|
365
|
+
required: [ "channel" ]
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "slack_post_message",
|
|
370
|
+
description: "Post a message to a Slack channel as the workspace's bot, or reply in a thread.",
|
|
371
|
+
parameters: {
|
|
372
|
+
type: "object",
|
|
373
|
+
properties: {
|
|
374
|
+
channel: { type: "string", description: "The channel id, e.g. C0123456789" },
|
|
375
|
+
text: { type: "string", description: "The message, in Slack mrkdwn" },
|
|
376
|
+
thread_ts: { type: "string", description: "Reply in the thread of this message ts" }
|
|
377
|
+
},
|
|
378
|
+
required: [ "channel", "text" ]
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
],
|
|
382
|
+
# A generation on the run's own provider, model and credentials,
|
|
383
|
+
# routed by AgentExecutionService.
|
|
384
|
+
"translate" => [
|
|
385
|
+
{
|
|
386
|
+
name: "translate_text",
|
|
387
|
+
description: "Translate text into another language, keeping its formatting, code and placeholders.",
|
|
388
|
+
parameters: {
|
|
389
|
+
type: "object",
|
|
390
|
+
properties: {
|
|
391
|
+
text: { type: "string", description: "The text to translate" },
|
|
392
|
+
target_language: { type: "string", description: "The language to translate into, e.g. Spanish or ja" },
|
|
393
|
+
source_language: { type: "string", description: "The text's language, when known" }
|
|
394
|
+
},
|
|
395
|
+
required: [ "text", "target_language" ]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
],
|
|
265
399
|
# Memory tools mirror solid_agent's HasMemory contract. They are NOT in
|
|
266
400
|
# FUNCTIONS below — execution is subject-bound, so AgentExecutionService
|
|
267
401
|
# routes them to the run's AgentMemory instead of this module.
|
|
@@ -315,6 +449,19 @@ module ActionAgent
|
|
|
315
449
|
# AgentExecutionService.
|
|
316
450
|
INPUT_FUNCTIONS = %w[ask_user request_approval request_secret].freeze
|
|
317
451
|
|
|
452
|
+
# The functions with side effects outside the conversation, by the
|
|
453
|
+
# capability that exposes them. They wait for a person's approval unless
|
|
454
|
+
# the agent's approval list says otherwise: Agent puts them on the list
|
|
455
|
+
# when a capability is gained without one (a template, the agents API),
|
|
456
|
+
# and the builder and the Tools tab add them when a capability is
|
|
457
|
+
# switched on (frontend/utils/builderRoster.mjs keeps the same table).
|
|
458
|
+
APPROVAL_BY_DEFAULT = {
|
|
459
|
+
"terminal" => %w[run_command],
|
|
460
|
+
"filesystem" => %w[write_file],
|
|
461
|
+
"edit" => %w[edit_file],
|
|
462
|
+
"slack" => %w[slack_post_message]
|
|
463
|
+
}.freeze
|
|
464
|
+
|
|
318
465
|
# Function name => implementation method, for routing tool calls.
|
|
319
466
|
FUNCTIONS = {
|
|
320
467
|
"fetch_url" => :fetch_url,
|
|
@@ -365,6 +512,12 @@ module ActionAgent
|
|
|
365
512
|
end.reject { |definition| definition[:name].to_s == REQUEST_SECRET_DEFINITION[:name] }
|
|
366
513
|
end
|
|
367
514
|
|
|
515
|
+
# The approval list +tool_names+ get by default: the side-effecting
|
|
516
|
+
# functions of the capabilities among them (APPROVAL_BY_DEFAULT).
|
|
517
|
+
def default_approvals(tool_names)
|
|
518
|
+
Array(tool_names).flat_map { |name| APPROVAL_BY_DEFAULT[name.to_s] || [] }.uniq
|
|
519
|
+
end
|
|
520
|
+
|
|
368
521
|
# Whether runs may use the process-wide browser: never in a
|
|
369
522
|
# multi-tenant install.
|
|
370
523
|
def shared_browser?
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ActionAgent
|
|
8
|
+
# Used to give a client that runs other people's code, such as a Claude
|
|
9
|
+
# Code turn, the sandbox's browser without its master token: the sidecar
|
|
10
|
+
# mints a client token for /mcp only (POST /tokens), labelled with who it
|
|
11
|
+
# is for, and the token is revoked once the turn ends (DELETE /tokens/:id).
|
|
12
|
+
# Its shell commands inherit what Claude Code was given, so a token they
|
|
13
|
+
# could read must be worthless once the turn is over.
|
|
14
|
+
class BrowserClientToken
|
|
15
|
+
TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :token, :mcp_url
|
|
20
|
+
|
|
21
|
+
# Mints a token for +sandbox+'s running browser.
|
|
22
|
+
#
|
|
23
|
+
# @param sandbox [SandboxSession]
|
|
24
|
+
# @param label [String] what the live view calls the client
|
|
25
|
+
# @return [BrowserClientToken]
|
|
26
|
+
# @raise [Error] when the browser is not running or refuses
|
|
27
|
+
def self.mint(sandbox, label:)
|
|
28
|
+
raise Error, "The sandbox's browser is not running" unless sandbox.browser_running? && sandbox.browser_token.present?
|
|
29
|
+
|
|
30
|
+
mcp_url = URI(sandbox.browser_mcp_url)
|
|
31
|
+
body = request(Net::HTTP::Post, tokens_uri(mcp_url), sandbox.browser_token, JSON.generate(label: label))
|
|
32
|
+
new(id: body.fetch("id"), token: body.fetch("token"), mcp_url: mcp_url.to_s, sandbox: sandbox)
|
|
33
|
+
rescue KeyError, JSON::ParserError
|
|
34
|
+
raise Error, "The browser answered a token request without one"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(id:, token:, mcp_url:, sandbox:)
|
|
38
|
+
@id = id
|
|
39
|
+
@token = token
|
|
40
|
+
@mcp_url = mcp_url
|
|
41
|
+
@sandbox = sandbox
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Revokes the token. A browser that stopped meanwhile took its tokens
|
|
45
|
+
# with it, so a failure here is only logged.
|
|
46
|
+
def revoke!
|
|
47
|
+
self.class.request(Net::HTTP::Delete, self.class.tokens_uri(URI(@mcp_url), @id), @sandbox.browser_token)
|
|
48
|
+
true
|
|
49
|
+
rescue Error, SystemCallError, IOError, Timeout::Error, URI::Error => e
|
|
50
|
+
Rails.logger.info("[ActionAgent] browser client token #{@id} not revoked: #{e.message}")
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The Claude Code --mcp-config document reaching the browser as the
|
|
55
|
+
# "browser" server with this token.
|
|
56
|
+
def mcp_config
|
|
57
|
+
{ mcpServers: { browser: { type: "http", url: mcp_url, headers: { "Authorization" => "Bearer #{token}" } } } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def tokens_uri(mcp_url, id = nil)
|
|
62
|
+
uri = mcp_url.dup
|
|
63
|
+
uri.path = id ? "/tokens/#{id}" : "/tokens"
|
|
64
|
+
uri.query = nil
|
|
65
|
+
uri
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Hash] the parsed body
|
|
69
|
+
def request(klass, uri, master_token, body = nil)
|
|
70
|
+
raise Error, "Only a loopback browser is reached this way" unless %w[127.0.0.1 localhost ::1 [::1]].include?(uri.host)
|
|
71
|
+
|
|
72
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
73
|
+
http.open_timeout = TIMEOUT
|
|
74
|
+
http.read_timeout = TIMEOUT
|
|
75
|
+
request = klass.new(uri)
|
|
76
|
+
request["Authorization"] = "Bearer #{master_token}"
|
|
77
|
+
request["Content-Type"] = "application/json"
|
|
78
|
+
request.body = body if body
|
|
79
|
+
response = http.request(request)
|
|
80
|
+
raise Error, "The browser answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
81
|
+
|
|
82
|
+
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# The toolbox capabilities that work in a checkout: terminal (run_command),
|
|
5
|
+
# filesystem (list_files, read_file, write_file), edit (edit_file) and
|
|
6
|
+
# database (query_database). Each acts on the checkout sandbox the run was
|
|
7
|
+
# given (AgentRun#sandbox_id — the runner's and an evaluation's
|
|
8
|
+
# `sandbox_id`), through SandboxOrchestrator, so they reach exactly what a
|
|
9
|
+
# Claude Code session in that checkout reaches.
|
|
10
|
+
#
|
|
11
|
+
# A run given no checkout gets an error saying how to give it one, rather
|
|
12
|
+
# than a tool that silently does nothing. Results and errors are returned
|
|
13
|
+
# as hashes for the model to read.
|
|
14
|
+
class CheckoutTools
|
|
15
|
+
FUNCTIONS = %w[run_command list_files read_file write_file edit_file query_database].freeze
|
|
16
|
+
|
|
17
|
+
# Rows query_database returns at most, and how long it may take, Rails
|
|
18
|
+
# boot included.
|
|
19
|
+
MAX_QUERY_ROWS = 200
|
|
20
|
+
QUERY_TIMEOUT = 90
|
|
21
|
+
# The line the query script's JSON follows, so whatever the app prints
|
|
22
|
+
# while booting is not mistaken for the result.
|
|
23
|
+
QUERY_MARKER = "__ACTION_AGENT_QUERY__"
|
|
24
|
+
|
|
25
|
+
NO_CHECKOUT = "This run has no checkout sandbox. Choose one under Checkout on the agent's Run page " \
|
|
26
|
+
"(or run an evaluation against one), then run again."
|
|
27
|
+
|
|
28
|
+
def self.function?(name)
|
|
29
|
+
FUNCTIONS.include?(name.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param sandbox [SandboxSession, nil] the run's checkout, or nil
|
|
33
|
+
def initialize(sandbox, orchestrator: nil)
|
|
34
|
+
@sandbox = sandbox
|
|
35
|
+
@orchestrator = orchestrator
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def call(name, kwargs)
|
|
40
|
+
return { error: "Unknown tool: #{name}" } unless self.class.function?(name)
|
|
41
|
+
return { error: NO_CHECKOUT } unless @sandbox
|
|
42
|
+
unless @sandbox.ready? || @sandbox.running?
|
|
43
|
+
return { error: "The checkout sandbox #{@sandbox.session_id} is #{@sandbox.status}; start it again and rerun" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
args = kwargs.to_h.symbolize_keys
|
|
47
|
+
case name.to_s
|
|
48
|
+
when "run_command" then run_command(**args.slice(:command, :timeout_seconds))
|
|
49
|
+
when "list_files" then list_files(**args.slice(:path))
|
|
50
|
+
when "read_file" then read_file(**args.slice(:path))
|
|
51
|
+
when "write_file" then write_file(**args.slice(:path, :content))
|
|
52
|
+
when "edit_file" then edit_file(**args.slice(:path, :old_text, :new_text))
|
|
53
|
+
when "query_database" then query_database(**args.slice(:sql))
|
|
54
|
+
end
|
|
55
|
+
rescue ArgumentError, SandboxOrchestrator::UnsupportedBackendError, LocalSandboxBackend::Error => e
|
|
56
|
+
{ error: e.message }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_command(command:, timeout_seconds: nil)
|
|
60
|
+
orchestrator.run_command(@sandbox, command.to_s, timeout: timeout_seconds&.to_i)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_files(path: nil)
|
|
64
|
+
orchestrator.list_files(@sandbox, path.presence)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_file(path:)
|
|
68
|
+
content = orchestrator.read_file(@sandbox, path.to_s)
|
|
69
|
+
return { error: "#{path} does not exist in the checkout" } if content.nil?
|
|
70
|
+
|
|
71
|
+
text = content.dup.force_encoding(Encoding::UTF_8)
|
|
72
|
+
return { error: "#{path} is not a text file" } unless text.valid_encoding?
|
|
73
|
+
|
|
74
|
+
{ path: path, content: text }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_file(path:, content:)
|
|
78
|
+
orchestrator.write_file(@sandbox, path.to_s, content.to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One exact replacement, the edit a model can say precisely: +old_text+
|
|
82
|
+
# must appear exactly once, so the edit lands where it was meant to.
|
|
83
|
+
def edit_file(path:, old_text:, new_text:)
|
|
84
|
+
current = read_file(path: path)
|
|
85
|
+
return current if current[:error]
|
|
86
|
+
|
|
87
|
+
occurrences = current[:content].scan(old_text.to_s).size
|
|
88
|
+
return { error: "old_text is empty" } if old_text.to_s.empty?
|
|
89
|
+
return { error: "old_text was not found in #{path}" } if occurrences.zero?
|
|
90
|
+
return { error: "old_text appears #{occurrences} times in #{path}; include more context so it appears once" } if occurrences > 1
|
|
91
|
+
|
|
92
|
+
written = orchestrator.write_file(@sandbox, path.to_s, current[:content].sub(old_text.to_s) { new_text.to_s })
|
|
93
|
+
written.merge(replaced: 1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Runs +sql+ in the checkout's own Rails environment, against the
|
|
97
|
+
# database its boot provisioned, inside a read-only transaction that is
|
|
98
|
+
# rolled back (PostgreSQL, MySQL), or on a query_only connection (SQLite). The SQL travels base64-encoded inside the script, so
|
|
99
|
+
# nothing in it reaches the shell.
|
|
100
|
+
def query_database(sql:)
|
|
101
|
+
return { error: "No SQL to run" } if sql.to_s.strip.empty?
|
|
102
|
+
|
|
103
|
+
result = orchestrator.run_command(@sandbox, query_command(sql.to_s), timeout: QUERY_TIMEOUT)
|
|
104
|
+
line = result[:output].to_s.lines.reverse.find { |candidate| candidate.start_with?(QUERY_MARKER) }
|
|
105
|
+
unless line
|
|
106
|
+
return { error: "The query did not run", exit_status: result[:exit_status], output: result[:output].to_s.last(4_000) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
JSON.parse(line.delete_prefix(QUERY_MARKER))
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
{ error: "The query's result could not be read", output: result[:output].to_s.last(4_000) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def orchestrator
|
|
117
|
+
@orchestrator ||= SandboxOrchestrator.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def query_command(sql)
|
|
121
|
+
encoded = [ sql ].pack("m0")
|
|
122
|
+
script = <<~RUBY
|
|
123
|
+
sql = "#{encoded}".unpack1("m0").force_encoding("UTF-8")
|
|
124
|
+
connection = ActiveRecord::Base.connection
|
|
125
|
+
adapter = connection.adapter_name.downcase
|
|
126
|
+
out = nil
|
|
127
|
+
read = lambda do
|
|
128
|
+
result = connection.select_all(sql)
|
|
129
|
+
out = { columns: result.columns, rows: result.rows.first(#{MAX_QUERY_ROWS}), row_count: result.rows.size, truncated: result.rows.size > #{MAX_QUERY_ROWS} }
|
|
130
|
+
end
|
|
131
|
+
begin
|
|
132
|
+
if adapter.include?("sqlite")
|
|
133
|
+
# Rails opens SQLite transactions with BEGIN IMMEDIATE, which
|
|
134
|
+
# query_only refuses; query_only alone keeps the connection from writing.
|
|
135
|
+
connection.execute("PRAGMA query_only = ON")
|
|
136
|
+
read.call
|
|
137
|
+
else
|
|
138
|
+
connection.execute("SET SESSION TRANSACTION READ ONLY") if adapter.match?(/mysql|trilogy/)
|
|
139
|
+
connection.transaction do
|
|
140
|
+
connection.execute("SET TRANSACTION READ ONLY") if adapter.include?("postg")
|
|
141
|
+
read.call
|
|
142
|
+
raise ActiveRecord::Rollback
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
rescue => e
|
|
146
|
+
out = { error: e.message.to_s[0, 2000] }
|
|
147
|
+
end
|
|
148
|
+
puts "#{QUERY_MARKER}" + out.to_json
|
|
149
|
+
RUBY
|
|
150
|
+
|
|
151
|
+
"bin/rails runner #{Shellwords.escape(script)}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to carry on a Claude Code or Codex conversation: a chain of
|
|
5
|
+
# CodeSessions, one per turn, the first being the root (see CodeSession).
|
|
6
|
+
#
|
|
7
|
+
# A turn runs only after the one before it settled, one at a time per
|
|
8
|
+
# checkout. A message sent while a turn runs is queued in the conversation
|
|
9
|
+
# (.post!) and started once that turn settles (.advance!, called by
|
|
10
|
+
# CodeSessionJob). A turn resumes the CLI session the first turn started, so
|
|
11
|
+
# it sees everything said before. That session lives in the CLI's own
|
|
12
|
+
# configuration for the credential the conversation started with, so a
|
|
13
|
+
# conversation keeps that credential: a turn on another is refused.
|
|
14
|
+
module CodeConversation
|
|
15
|
+
# A conversation's length, and how many messages may wait behind its
|
|
16
|
+
# running turn.
|
|
17
|
+
MAX_TURNS = 50
|
|
18
|
+
MAX_QUEUED_TURNS = 3
|
|
19
|
+
MAX_NOTE_CHARACTERS = 2_000
|
|
20
|
+
|
|
21
|
+
class Refused < StandardError; end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Adds a turn to +root+'s conversation: queued, and started at once when
|
|
26
|
+
# nothing else runs in its checkout. The caller has checked the checkout
|
|
27
|
+
# can take a session (CodeSessionsController#refusal_for) and charged the
|
|
28
|
+
# execution.
|
|
29
|
+
#
|
|
30
|
+
# @param root [CodeSession] the conversation's first turn
|
|
31
|
+
# @param prompt [String] the person's message, exactly as typed
|
|
32
|
+
# @param user_id [Integer, nil] who sent it, billed for it
|
|
33
|
+
# @param credential_mode [String, nil] what the turn would run on now
|
|
34
|
+
# @param handoff [Hash, nil] { "taken_at", "released_at", "page_url", "by" }:
|
|
35
|
+
# the sender's own account of driving the browser since the last turn
|
|
36
|
+
# @param model [String, nil]
|
|
37
|
+
# @return [CodeSession] the new turn
|
|
38
|
+
# @raise [Refused]
|
|
39
|
+
def post!(root, prompt:, user_id:, credential_mode: nil, handoff: nil, model: nil)
|
|
40
|
+
raise Refused, "Continue the conversation from its first turn" unless root.root?
|
|
41
|
+
|
|
42
|
+
root.sandbox_session.with_lock do
|
|
43
|
+
turns = root.turns
|
|
44
|
+
raise Refused, "This conversation has reached its #{MAX_TURNS} turns: start a new one" if turns.size >= MAX_TURNS
|
|
45
|
+
raise Refused, "#{MAX_QUEUED_TURNS} messages are already waiting in this conversation" if turns.count(&:queued?) >= MAX_QUEUED_TURNS
|
|
46
|
+
if credential_mode && root.credential_mode && credential_mode != root.credential_mode
|
|
47
|
+
raise Refused, "This conversation ran on another Claude Code credential (#{root.credential_mode}): start a new conversation"
|
|
48
|
+
end
|
|
49
|
+
if (other = busy_elsewhere(root))
|
|
50
|
+
raise Refused, "Another #{other.runner == 'codex' ? 'Codex' : 'Claude Code'} session is #{other.status} in this sandbox"
|
|
51
|
+
end
|
|
52
|
+
raise Refused, "The first turn never started a session to continue: start a new conversation" if resume_id(root).nil?
|
|
53
|
+
|
|
54
|
+
turn = CodeSession.create!(
|
|
55
|
+
sandbox_session: root.sandbox_session,
|
|
56
|
+
conversation_root: root,
|
|
57
|
+
turn_index: turns.last.turn_index + 1,
|
|
58
|
+
prompt: CodeSession.strip_note_tags(prompt),
|
|
59
|
+
context_note: note_for(handoff),
|
|
60
|
+
model: model.presence || root.model,
|
|
61
|
+
runner: root.runner,
|
|
62
|
+
origin: root.origin,
|
|
63
|
+
credential_mode: root.credential_mode,
|
|
64
|
+
resume_session_id: resume_id(root),
|
|
65
|
+
user_id: user_id,
|
|
66
|
+
account_id: root.account_id
|
|
67
|
+
)
|
|
68
|
+
end.tap { advance!(root) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Starts the oldest queued turn of +root+'s conversation, when no turn of
|
|
72
|
+
# it runs. Called by CodeSessionJob once a turn settled, and by .post!.
|
|
73
|
+
#
|
|
74
|
+
# @return [CodeSession, nil] the turn started
|
|
75
|
+
def advance!(root)
|
|
76
|
+
root = root.root
|
|
77
|
+
turn = root.sandbox_session.with_lock do
|
|
78
|
+
turns = root.turns
|
|
79
|
+
next nil if turns.any?(&:running?)
|
|
80
|
+
|
|
81
|
+
# The root starts by itself (CodeSessionsController#create); a
|
|
82
|
+
# follow-up waits for it. The oldest queued follow-up goes next.
|
|
83
|
+
# Enqueuing one twice does no harm: CodeSessionJob claims a turn
|
|
84
|
+
# atomically, and a second job finds it no longer queued.
|
|
85
|
+
next nil if root.queued?
|
|
86
|
+
|
|
87
|
+
turns.find { |candidate| candidate.queued? && !candidate.root? }
|
|
88
|
+
end
|
|
89
|
+
CodeSessionJob.perform_later(turn.id) if turn
|
|
90
|
+
turn
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The CLI session +root+'s conversation resumes: the id the first turn
|
|
94
|
+
# started with, or, for a Codex conversation, the thread its first turn
|
|
95
|
+
# reported.
|
|
96
|
+
def resume_id(root)
|
|
97
|
+
root.runner == "codex" ? root.runner_session_id.presence : root.resume_session_id.presence
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A queued or running session in +root+'s checkout that is not a turn of
|
|
101
|
+
# its conversation.
|
|
102
|
+
def busy_elsewhere(root)
|
|
103
|
+
root.sandbox_session.code_sessions.where(status: [ :queued, :running ])
|
|
104
|
+
.where.not(id: root.id).where("conversation_root_id IS NULL OR conversation_root_id <> ?", root.id).first
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What the dashboard tells Claude ahead of the message, from the sender's
|
|
108
|
+
# account of driving the browser, or nil.
|
|
109
|
+
def note_for(handoff)
|
|
110
|
+
return nil unless handoff.respond_to?(:to_h)
|
|
111
|
+
|
|
112
|
+
handoff = handoff.to_h.transform_keys(&:to_s)
|
|
113
|
+
by = handoff["by"].to_s.gsub(/[[:cntrl:]]/, " ").strip.presence || "The person"
|
|
114
|
+
taken = timestamp(handoff["taken_at"])
|
|
115
|
+
released = timestamp(handoff["released_at"])
|
|
116
|
+
return nil unless taken || released
|
|
117
|
+
|
|
118
|
+
page = handoff["page_url"].to_s[%r{\Ahttps?://[^\s<>"]{1,500}\z}]
|
|
119
|
+
parts = []
|
|
120
|
+
parts << "#{by} took control of the browser at #{taken}" if taken
|
|
121
|
+
parts << "handed it back at #{released}" if released
|
|
122
|
+
text = "#{parts.join(' and ')}."
|
|
123
|
+
text += " The page on screen was #{page}." if page
|
|
124
|
+
"#{text} The page may have changed: take a snapshot before acting.".truncate(MAX_NOTE_CHARACTERS)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def timestamp(value)
|
|
128
|
+
Time.iso8601(value.to_s).utc.strftime("%H:%M:%S UTC")
|
|
129
|
+
rescue ArgumentError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# How Codex sessions authenticate (ActionAgent.codex_auth), and the one rule
|
|
5
|
+
# for whether an owner's Codex is "connected", shared by the sandbox
|
|
6
|
+
# listing, the assistant's configuration and the session API so they never
|
|
7
|
+
# disagree. The Codex counterpart of ClaudeCodeAuth.
|
|
8
|
+
#
|
|
9
|
+
# :api_key the owner's OpenAI API key, stored as a ProviderKey and
|
|
10
|
+
# handed to the session as CODEX_API_KEY.
|
|
11
|
+
# :local_login the login of the machine the dashboard runs on, as
|
|
12
|
+
# `codex login status` reports it (see
|
|
13
|
+
# LocalSandboxBackend.codex_login_status). The dashboard never
|
|
14
|
+
# sees that credential.
|
|
15
|
+
module CodexAuth
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @return [String] "api_key" or "local_login"
|
|
19
|
+
def mode
|
|
20
|
+
ActionAgent.codex_auth.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def local_login?
|
|
24
|
+
mode == "local_login"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Why +orchestrator+'s backend cannot run Codex sessions with the
|
|
28
|
+
# configured authentication, or nil. A machine's own login is the
|
|
29
|
+
# dashboard user's, so only a backend running sessions as that user, on
|
|
30
|
+
# that machine, may use it: a container or a remote host would either
|
|
31
|
+
# find no login or need it copied there, which is what this mode exists
|
|
32
|
+
# to never do.
|
|
33
|
+
def backend_refusal(orchestrator)
|
|
34
|
+
return unless local_login?
|
|
35
|
+
return if orchestrator.local?
|
|
36
|
+
|
|
37
|
+
"ActionAgent.codex_auth = :local_login uses this machine's own Codex login, so it works only " \
|
|
38
|
+
"with the :local sandbox backend, not #{orchestrator.backend_name}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether the owner of +provider_keys+ (a ProviderKey scope already
|
|
42
|
+
# narrowed to them) has an API key Codex can run on.
|
|
43
|
+
def api_key_connected?(provider_keys)
|
|
44
|
+
provider_keys.where(provider: "codex").any? { |key| key.runtime_environment.present? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What the dashboard reports about Codex for the owner of
|
|
48
|
+
# +provider_keys+: booleans and the login's method, never a credential.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash] { mode:, connected:, login: } (login for :local_login only)
|
|
51
|
+
def status(provider_keys)
|
|
52
|
+
if local_login?
|
|
53
|
+
# Only the :local backend uses the login, and no other one needs
|
|
54
|
+
# this machine's CLI asked about it.
|
|
55
|
+
login = local_backend? ? LocalSandboxBackend.codex_login_status : LocalSandboxBackend::LOGGED_OUT
|
|
56
|
+
{ mode: mode, connected: login[:logged_in], login: login.slice(:logged_in, :auth_method) }
|
|
57
|
+
else
|
|
58
|
+
{ mode: mode, connected: api_key_connected?(provider_keys) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A backend that cannot even be loaded is not the :local one.
|
|
63
|
+
def local_backend?
|
|
64
|
+
SandboxOrchestrator.new.local?
|
|
65
|
+
rescue StandardError, LoadError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Why a Codex session cannot start in +sandbox+ for want of credentials,
|
|
70
|
+
# or nil.
|
|
71
|
+
def credential_refusal(sandbox)
|
|
72
|
+
if local_login?
|
|
73
|
+
return if LocalSandboxBackend.codex_login_status[:logged_in]
|
|
74
|
+
|
|
75
|
+
"Codex is not logged in on this machine: run `codex login` as the user the dashboard runs as"
|
|
76
|
+
elsif sandbox.runtime_environment(runner: "codex").blank?
|
|
77
|
+
"Codex is not connected: connect an OpenAI API key in Settings -> Integrations first"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|