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
|
@@ -292,6 +292,29 @@ module ActionAgent
|
|
|
292
292
|
).as_json
|
|
293
293
|
end
|
|
294
294
|
|
|
295
|
+
# GET /api/agents/tool_roster
|
|
296
|
+
#
|
|
297
|
+
# The roster an agent not created yet can be given — what the builder's
|
|
298
|
+
# Configure step offers. The same rows as #tool_roster for a saved
|
|
299
|
+
# agent with nothing enabled: every tool the toolbox or the host's
|
|
300
|
+
# schema tools implement, and every MCP service the catalog, the host's
|
|
301
|
+
# registrations and the caller's live checkout runtimes describe, each
|
|
302
|
+
# saying whether a run can call it. Usage reads the workspace instead of
|
|
303
|
+
# an agent's history: which services its agents already enable.
|
|
304
|
+
def new_tool_roster
|
|
305
|
+
agent = owner_agents.build
|
|
306
|
+
runtimes = SandboxSession.runtime_server_listings(
|
|
307
|
+
SandboxSession.for_owner(agent.owner).where(id: owned(SandboxSession).select(:id))
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
render json: AgentToolRoster.new(
|
|
311
|
+
agent: agent,
|
|
312
|
+
traces: owned_traces,
|
|
313
|
+
runtimes: runtimes,
|
|
314
|
+
workspace_agents: owner_agents
|
|
315
|
+
).as_json
|
|
316
|
+
end
|
|
317
|
+
|
|
295
318
|
# GET /api/agents/:id/recorded_models
|
|
296
319
|
#
|
|
297
320
|
# The model names this agent's generations were recorded under, most
|
|
@@ -4,7 +4,8 @@ module ActionAgent
|
|
|
4
4
|
module Api
|
|
5
5
|
# Headless Claude Code sessions inside one of the caller's checkout
|
|
6
6
|
# sandboxes (Settings -> Integrations): start one with a prompt, poll its
|
|
7
|
-
# transcript, cancel it
|
|
7
|
+
# transcript, cancel it, and carry its conversation on with more
|
|
8
|
+
# messages (#messages, CodeConversation).
|
|
8
9
|
#
|
|
9
10
|
# A session runs on the owner's Anthropic API key (or, with
|
|
10
11
|
# ActionAgent.claude_code_auth = :local_login, this machine's own Claude
|
|
@@ -18,9 +19,9 @@ module ActionAgent
|
|
|
18
19
|
VERIFICATION_HOLD = 1.hour
|
|
19
20
|
|
|
20
21
|
before_action :require_owner!
|
|
21
|
-
before_action :require_execution_enabled!, only: [ :create ]
|
|
22
|
+
before_action :require_execution_enabled!, only: [ :create, :messages ]
|
|
22
23
|
before_action :set_sandbox
|
|
23
|
-
before_action :set_code_session, only: [ :show, :cancel ]
|
|
24
|
+
before_action :set_code_session, only: [ :show, :cancel, :messages ]
|
|
24
25
|
|
|
25
26
|
# A model name as Claude Code's --model takes it ("sonnet",
|
|
26
27
|
# "claude-sonnet-4-5", "claude-sonnet-4-5[1m]"). It becomes an argument
|
|
@@ -48,14 +49,17 @@ module ActionAgent
|
|
|
48
49
|
|
|
49
50
|
code_session = CodeSession.new(
|
|
50
51
|
sandbox_session: @sandbox,
|
|
51
|
-
prompt: string_param(:prompt),
|
|
52
|
+
prompt: CodeSession.strip_note_tags(string_param(:prompt)).presence,
|
|
52
53
|
model: string_param(:model).presence,
|
|
53
54
|
runner: runner,
|
|
55
|
+
# A conversation started to chat (the project's Sessions tab, or
|
|
56
|
+
# Interactions) rather than from Settings.
|
|
57
|
+
origin: params[:origin] == "chat" ? "chat" : "settings",
|
|
54
58
|
# Subscription use always belongs to the person who clicked Run.
|
|
55
59
|
user_id: current_user.respond_to?(:id) ? current_user.id : @sandbox.try(:user_id),
|
|
56
60
|
account_id: @sandbox.try(:account_id)
|
|
57
61
|
)
|
|
58
|
-
code_session.credential_mode = runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) :
|
|
62
|
+
code_session.credential_mode = runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) : CodexAuth.mode
|
|
59
63
|
attach_fix!(code_session) if params[:evaluation_run_id].present?
|
|
60
64
|
if (error = invalid_request(code_session))
|
|
61
65
|
return render json: { error: error }, status: :unprocessable_entity
|
|
@@ -110,6 +114,43 @@ module ActionAgent
|
|
|
110
114
|
render json: { code_session: @code_session.summary }
|
|
111
115
|
end
|
|
112
116
|
|
|
117
|
+
# POST /api/sandboxes/:sandbox_id/code_sessions/:id/messages
|
|
118
|
+
# { prompt, model?, handoff?: { by, taken_at, released_at, page_url } }
|
|
119
|
+
# Adds a turn to the conversation +:id+ belongs to. 201 when it starts
|
|
120
|
+
# now, 202 when it waits behind the turn that runs.
|
|
121
|
+
def messages
|
|
122
|
+
root = @code_session.root
|
|
123
|
+
return unless authorize_action!(:chat_code_session, root)
|
|
124
|
+
|
|
125
|
+
if (refusal = refusal_for(@sandbox, runner: root.runner))
|
|
126
|
+
return render json: { error: refusal }, status: :unprocessable_entity
|
|
127
|
+
end
|
|
128
|
+
if root.credential_mode == "sandbox_login" && @sandbox.claude_login_user_id != current_user&.id
|
|
129
|
+
return render json: { error: "Only the person signed in to Claude Code in this sandbox can continue its conversations" },
|
|
130
|
+
status: :forbidden
|
|
131
|
+
end
|
|
132
|
+
prompt = CodeSession.strip_note_tags(string_param(:prompt)).strip
|
|
133
|
+
return render json: { error: "Write a message" }, status: :unprocessable_entity if prompt.empty?
|
|
134
|
+
model = string_param(:model).presence
|
|
135
|
+
return render json: { error: "model is not a Claude Code model name" }, status: :unprocessable_entity if model && !model.match?(MODEL_NAME)
|
|
136
|
+
|
|
137
|
+
enforce_execution_quota!
|
|
138
|
+
return if performed?
|
|
139
|
+
|
|
140
|
+
handoff = params[:handoff].respond_to?(:permit) ? params[:handoff].permit(:by, :taken_at, :released_at, :page_url).to_h : nil
|
|
141
|
+
mode = root.runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) : nil
|
|
142
|
+
turn = CodeConversation.post!(root, prompt: prompt, user_id: current_user.respond_to?(:id) ? current_user.id : nil,
|
|
143
|
+
credential_mode: mode, handoff: handoff, model: model)
|
|
144
|
+
record_execution_usage
|
|
145
|
+
|
|
146
|
+
behind = root.reload.turns.any? { |other| other.id != turn.id && (other.running? || other.queued?) && other.id < turn.id }
|
|
147
|
+
render json: { code_session: turn.reload.summary, waiting: behind }, status: behind ? :accepted : :created
|
|
148
|
+
rescue CodeConversation::Refused => e
|
|
149
|
+
render json: { error: e.message }, status: :conflict
|
|
150
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
151
|
+
render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
|
|
152
|
+
end
|
|
153
|
+
|
|
113
154
|
private
|
|
114
155
|
|
|
115
156
|
# A checkout runs on its account's GitHub token and Claude Code
|
|
@@ -141,10 +182,8 @@ module ActionAgent
|
|
|
141
182
|
if (refusal = ClaudeCodeAuth.login_owner_refusal(sandbox, user_id: current_user&.id))
|
|
142
183
|
return refusal
|
|
143
184
|
end
|
|
144
|
-
if runner == "codex"
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
end
|
|
185
|
+
return CodexAuth.backend_refusal(orchestrator) || CodexAuth.credential_refusal(sandbox) if runner == "codex"
|
|
186
|
+
|
|
148
187
|
ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox, user_id: current_user&.id)
|
|
149
188
|
end
|
|
150
189
|
|
|
@@ -185,7 +224,7 @@ module ActionAgent
|
|
|
185
224
|
raise EvaluationFix::Invalid, "The previous session must implement this same fix"
|
|
186
225
|
end
|
|
187
226
|
session.assign_attributes(evaluation_run: run, fix_item: fix.item, previous_code_session: previous,
|
|
188
|
-
prompt: fix.prompt(previous: previous))
|
|
227
|
+
prompt: fix.prompt(previous: previous), origin: "evaluation_fix")
|
|
189
228
|
end
|
|
190
229
|
|
|
191
230
|
rescue_from EvaluationFix::Invalid do |error|
|
|
@@ -25,15 +25,28 @@ module ActionAgent
|
|
|
25
25
|
# 401 — a missing or unknown key
|
|
26
26
|
# 501 — an install with no evaluation tables, or not yet migrated
|
|
27
27
|
# 503 — another import for the same agent held the lock too long
|
|
28
|
+
#
|
|
29
|
+
# GET <mount>/api/evaluation_reports/:run_id returns a stored run as the
|
|
30
|
+
# same version-1 envelope (EvaluationReportExport), so it can be posted to
|
|
31
|
+
# another install, or back to this one as an identical retry. Responds
|
|
32
|
+
# 200 with the envelope (or, with `format=html` or `format=md`, the
|
|
33
|
+
# report page or its Markdown), 404 for a run_id the key's tenant holds
|
|
34
|
+
# no run under, 422 for a run that is not complete or an unknown format,
|
|
35
|
+
# 401 as above and also on a single-tenant install that has no
|
|
36
|
+
# ingest_api_key, and 501 as above.
|
|
28
37
|
class EvaluationReportsController < ActionController::API
|
|
29
38
|
include IngestAuthentication
|
|
30
39
|
|
|
40
|
+
# What #show serves for each `format`.
|
|
41
|
+
EXPORT_FORMATS = { "json" => "application/json", "html" => "text/html", "md" => "text/markdown" }.freeze
|
|
42
|
+
|
|
31
43
|
# New reports one key may store per minute.
|
|
32
44
|
RATE_LIMIT = 30
|
|
33
45
|
|
|
34
46
|
wrap_parameters false
|
|
35
47
|
|
|
36
|
-
before_action :require_json
|
|
48
|
+
before_action :require_json!, only: :create
|
|
49
|
+
before_action :require_export_key!, only: :show
|
|
37
50
|
before_action :require_report_store!
|
|
38
51
|
|
|
39
52
|
# POST <mount>/api/evaluation_reports
|
|
@@ -64,6 +77,41 @@ module ActionAgent
|
|
|
64
77
|
render json: { error: e.message }, status: :service_unavailable
|
|
65
78
|
end
|
|
66
79
|
|
|
80
|
+
# GET <mount>/api/evaluation_reports/:run_id
|
|
81
|
+
#
|
|
82
|
+
# :run_id is an imported run's run_id as it was published, or the
|
|
83
|
+
# "dashboard-run-<id>-<stamp>" a run the dashboard executed exports
|
|
84
|
+
# under (EvaluationReportExport.run_id_for). Found within the key's
|
|
85
|
+
# tenant exactly as #create finds a stored run. `format=html` returns
|
|
86
|
+
# the run's self-contained report page (text/html) as run_report renders
|
|
87
|
+
# it, and `format=md` its Markdown (text/markdown), instead of the
|
|
88
|
+
# envelope; any other format is a 422. `download=1` sends any of them
|
|
89
|
+
# as an attachment named like the dashboard's downloads.
|
|
90
|
+
def show
|
|
91
|
+
run = EvaluationReportExport.find(params[:run_id], account: @account)
|
|
92
|
+
return render json: { error: "No evaluation report with that run_id" }, status: :not_found unless run
|
|
93
|
+
|
|
94
|
+
format = params[:format].presence || "json"
|
|
95
|
+
unless EXPORT_FORMATS.key?(format)
|
|
96
|
+
return render json: { error: "format is one of #{EXPORT_FORMATS.keys.join(', ')}" }, status: :unprocessable_entity
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
export = EvaluationReportExport.new(run)
|
|
100
|
+
export.exportable!
|
|
101
|
+
body =
|
|
102
|
+
case format
|
|
103
|
+
when "json" then export.to_json
|
|
104
|
+
when "md" then export.markdown
|
|
105
|
+
# The page run_report serves, its fix actions linking into this
|
|
106
|
+
# dashboard by absolute path; no theme, so it follows the viewer.
|
|
107
|
+
when "html" then run.to_report(links: run.report_links(mount: request.script_name)).to_html
|
|
108
|
+
end
|
|
109
|
+
send_data body, type: EXPORT_FORMATS.fetch(format), disposition: download? ? "attachment" : "inline",
|
|
110
|
+
filename: export.filename(format)
|
|
111
|
+
rescue EvaluationReportExport::NotExportable => e
|
|
112
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
113
|
+
end
|
|
114
|
+
|
|
67
115
|
private
|
|
68
116
|
|
|
69
117
|
# Rails reads and parses a JSON body into params before any callback
|
|
@@ -74,6 +122,18 @@ module ActionAgent
|
|
|
74
122
|
super
|
|
75
123
|
end
|
|
76
124
|
|
|
125
|
+
# A single-tenant install with no ingest key takes reports from anyone
|
|
126
|
+
# who reaches the mount, which a host keeps off the network. Reading a
|
|
127
|
+
# run back is different: every stored run, prompts and answers
|
|
128
|
+
# included, would be open under a guessable run_id. So the export
|
|
129
|
+
# needs the key to exist; a multi-tenant install authenticates the
|
|
130
|
+
# tenant's key already.
|
|
131
|
+
def require_export_key!
|
|
132
|
+
return if ActionAgent.multi_tenant? || ActionAgent.ingest_api_key.present?
|
|
133
|
+
|
|
134
|
+
render json: { error: "Set ActionAgent.ingest_api_key to export evaluation reports" }, status: :unauthorized
|
|
135
|
+
end
|
|
136
|
+
|
|
77
137
|
# A cross-site page can send a text/plain or form POST without a CORS
|
|
78
138
|
# preflight; it cannot send application/json.
|
|
79
139
|
def require_json!
|
|
@@ -82,6 +142,10 @@ module ActionAgent
|
|
|
82
142
|
render json: { error: "Content-Type must be application/json" }, status: :unsupported_media_type
|
|
83
143
|
end
|
|
84
144
|
|
|
145
|
+
def download?
|
|
146
|
+
ActiveModel::Type::Boolean.new.cast(params[:download]) == true
|
|
147
|
+
end
|
|
148
|
+
|
|
85
149
|
def require_report_store!
|
|
86
150
|
reason = EvaluationReportImport.unavailable_reason
|
|
87
151
|
render json: { error: reason }, status: :not_implemented if reason
|
|
@@ -110,7 +174,7 @@ module ActionAgent
|
|
|
110
174
|
{
|
|
111
175
|
id: run.id,
|
|
112
176
|
evaluation_id: run.evaluation_id,
|
|
113
|
-
run_id: run
|
|
177
|
+
run_id: EvaluationReportExport.run_id_for(run),
|
|
114
178
|
status: run.status,
|
|
115
179
|
duplicate: duplicate,
|
|
116
180
|
url: run_url(run)
|
|
@@ -48,7 +48,7 @@ module ActionAgent
|
|
|
48
48
|
#
|
|
49
49
|
# Three fields feed the dashboard's model pickers. They describe the
|
|
50
50
|
# credentials of #picker_credentials_owner:
|
|
51
|
-
# - judge_provider: the provider
|
|
51
|
+
# - judge_provider: the default provider of an unpinned judge, null
|
|
52
52
|
# when none has credentials or the lookup
|
|
53
53
|
# raised
|
|
54
54
|
# - judge_provider_error: true when that lookup raised, as it does
|
|
@@ -255,14 +255,44 @@ module ActionAgent
|
|
|
255
255
|
# the page follows the viewer's own preference. The page is served
|
|
256
256
|
# outside the React app, so its fix item actions link at the absolute
|
|
257
257
|
# mount path (request.script_name) rather than relative to it.
|
|
258
|
+
#
|
|
259
|
+
# `report.json` (or `?format=json`) returns the run instead as the
|
|
260
|
+
# version-1 envelope the evaluation report collector takes
|
|
261
|
+
# (EvaluationReportExport), the same the API-key export at
|
|
262
|
+
# GET /api/evaluation_reports/:run_id serves; 422 for a run that is not
|
|
263
|
+
# complete. `report.md` (or `?format=md`) returns it as GitHub-flavored
|
|
264
|
+
# Markdown, the whole report or one `part` — `verdict`, `fix_items`
|
|
265
|
+
# (narrowed by `model`, a model label) or `scenario` (named by
|
|
266
|
+
# `scenario`, its key; 404 for one the run did not replay) — which the
|
|
267
|
+
# dashboard's "Copy as Markdown" buttons read. `download=1` sends any of
|
|
268
|
+
# them as an attachment named for the evaluation and the run's number:
|
|
269
|
+
# `orders-eu-run-3.html`, `orders-eu-run-3-fix-items.md`.
|
|
258
270
|
def run_report
|
|
259
271
|
evaluation = evaluations_scope.find(params[:id])
|
|
260
272
|
run = evaluation.evaluation_runs.find(params[:run_id])
|
|
261
273
|
raise ActiveRecord::RecordNotFound unless evaluation.scenario_suite?
|
|
262
274
|
|
|
275
|
+
export = EvaluationReportExport.new(run)
|
|
276
|
+
if request.format.symbol == :md
|
|
277
|
+
body = export.markdown(part: params[:part], model: params[:model], scenario: params[:scenario])
|
|
278
|
+
return send_report(body, export.filename("md", part: params[:part]), "text/markdown") if download?
|
|
279
|
+
|
|
280
|
+
return render plain: body, content_type: "text/markdown"
|
|
281
|
+
end
|
|
282
|
+
if request.format.json?
|
|
283
|
+
body = export.to_json
|
|
284
|
+
return send_report(body, export.filename("json"), "application/json") if download?
|
|
285
|
+
|
|
286
|
+
return render json: body
|
|
287
|
+
end
|
|
288
|
+
|
|
263
289
|
report = run.to_report(links: run.report_links(mount: request.script_name))
|
|
290
|
+
html = report.to_html(theme: params[:theme])
|
|
291
|
+
return send_report(html, export.filename("html"), "text/html") if download?
|
|
264
292
|
|
|
265
|
-
render html:
|
|
293
|
+
render html: html.html_safe, layout: false
|
|
294
|
+
rescue EvaluationReportExport::NotExportable, EvaluationReportExport::UnknownPart => e
|
|
295
|
+
render json: { errors: [ e.message ] }, status: :unprocessable_entity
|
|
266
296
|
end
|
|
267
297
|
|
|
268
298
|
# DELETE /api/evaluations/:id
|
|
@@ -273,6 +303,14 @@ module ActionAgent
|
|
|
273
303
|
|
|
274
304
|
private
|
|
275
305
|
|
|
306
|
+
def download?
|
|
307
|
+
ActiveModel::Type::Boolean.new.cast(params[:download]) == true
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def send_report(body, filename, type)
|
|
311
|
+
send_data body, filename: filename, type: type, disposition: "attachment"
|
|
312
|
+
end
|
|
313
|
+
|
|
276
314
|
def include_archived?
|
|
277
315
|
ActiveModel::Type::Boolean.new.cast(params[:archived]) == true
|
|
278
316
|
end
|
|
@@ -423,7 +461,7 @@ module ActionAgent
|
|
|
423
461
|
|
|
424
462
|
def run_number(evaluation, run) = EvaluationSerializer.run_number(evaluation, run)
|
|
425
463
|
|
|
426
|
-
def safe_fix_items(run) = EvaluationSerializer.fix_items(run)
|
|
464
|
+
def safe_fix_items(run) = EvaluationSerializer.fix_items(run, markdown: true)
|
|
427
465
|
end
|
|
428
466
|
end
|
|
429
467
|
end
|
|
@@ -8,15 +8,32 @@ module ActionAgent
|
|
|
8
8
|
# A context is one agent's interaction stream (AgentContext +
|
|
9
9
|
# AgentMessages + AgentGenerations, recorded by SolidAgent::HasContext
|
|
10
10
|
# during execution), scoped to the current user's agents.
|
|
11
|
+
#
|
|
12
|
+
# Claude Code and Codex conversations in the caller's checkout sandboxes
|
|
13
|
+
# are interactions too ("code-<id>", CodeConversationSerializer).
|
|
14
|
+
#
|
|
15
|
+
# GET /api/interactions takes:
|
|
16
|
+
# source all (the default), agent, claude_code or codex
|
|
17
|
+
# project_id only that project's code conversations
|
|
18
|
+
# sandbox_id only that sandbox's (its session id)
|
|
19
|
+
# agent_id only that agent's contexts and traces; no code conversations
|
|
11
20
|
class InteractionsController < BaseController
|
|
12
21
|
before_action :require_owner!
|
|
13
22
|
|
|
14
23
|
DEFAULT_LIMIT = 50
|
|
15
24
|
MAX_WINDOW_MINUTES = 60 * 24 * 90
|
|
25
|
+
SOURCES = %w[all agent claude_code codex].freeze
|
|
16
26
|
|
|
17
27
|
# GET /api/interactions
|
|
18
28
|
def index
|
|
19
29
|
limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 200)
|
|
30
|
+
source = SOURCES.include?(params[:source]) ? params[:source] : "all"
|
|
31
|
+
coded = source == "all" ? code_conversations(limit) : (source == "agent" ? [] : code_conversations(limit, runner: source))
|
|
32
|
+
# A project or sandbox filter, or a code source, names code
|
|
33
|
+
# conversations only.
|
|
34
|
+
if source != "all" && source != "agent" || params[:project_id].present? || params[:sandbox_id].present?
|
|
35
|
+
return render json: { interactions: coded.first(limit) }
|
|
36
|
+
end
|
|
20
37
|
|
|
21
38
|
contexts = interactions_scope
|
|
22
39
|
.includes(:contextable)
|
|
@@ -53,12 +70,20 @@ module ActionAgent
|
|
|
53
70
|
reported = reported_traces(limit).map { |trace| TraceInteractionSerializer.summary(trace) }
|
|
54
71
|
|
|
55
72
|
render json: {
|
|
56
|
-
interactions: (persisted + reported).sort_by { |row| row[:last_activity_at].to_s }.reverse.first(limit)
|
|
73
|
+
interactions: (persisted + reported + coded).sort_by { |row| row[:last_activity_at].to_s }.reverse.first(limit)
|
|
57
74
|
}
|
|
58
75
|
end
|
|
59
76
|
|
|
60
77
|
# GET /api/interactions/:id
|
|
78
|
+
# For "code-<id>", ?after[<turn id>]=N sends each turn's transcript from
|
|
79
|
+
# event N onward.
|
|
61
80
|
def show
|
|
81
|
+
if (root_id = params[:id].to_s[/\Acode-(\d+)\z/, 1])
|
|
82
|
+
root = code_session_scope.conversation_roots.find(root_id)
|
|
83
|
+
after = params[:after].respond_to?(:permit) ? params[:after].permit!.to_h : {}
|
|
84
|
+
return render json: { interaction: CodeConversationSerializer.detail(root, after: after) }
|
|
85
|
+
end
|
|
86
|
+
|
|
62
87
|
if (trace_id = params[:id].to_s[/\Atrace-(\d+)\z/, 1])
|
|
63
88
|
trace = owned_traces.find(trace_id)
|
|
64
89
|
return render json: { interaction: TraceInteractionSerializer.detail(trace) }
|
|
@@ -78,6 +103,35 @@ module ActionAgent
|
|
|
78
103
|
|
|
79
104
|
private
|
|
80
105
|
|
|
106
|
+
# Code sessions in the caller's checkout sandboxes, of their current
|
|
107
|
+
# account in a multi-tenant install, as CodeSessionsController reaches
|
|
108
|
+
# them. Per-agent views list none.
|
|
109
|
+
def code_session_scope
|
|
110
|
+
sandboxes = owned(SandboxSession)
|
|
111
|
+
sandboxes = sandboxes.where(account_id: current_account.id) if current_account
|
|
112
|
+
CodeSession.where(sandbox_session_id: sandboxes.select(:id))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The newest code conversations, as interaction rows.
|
|
116
|
+
def code_conversations(limit, runner: nil)
|
|
117
|
+
return [] if params[:agent_id].present?
|
|
118
|
+
|
|
119
|
+
roots = code_session_scope.conversation_roots
|
|
120
|
+
roots = roots.where(runner: runner) if runner
|
|
121
|
+
if params[:sandbox_id].present?
|
|
122
|
+
roots = roots.where(sandbox_session_id: SandboxSession.where(session_id: params[:sandbox_id].to_s).select(:id))
|
|
123
|
+
end
|
|
124
|
+
if params[:project_id].present?
|
|
125
|
+
roots = roots.where(sandbox_session_id: SandboxSession.where(project_id: params[:project_id].to_i).select(:id))
|
|
126
|
+
end
|
|
127
|
+
roots = roots.where(updated_at: window_minutes.minutes.ago..) if window_minutes
|
|
128
|
+
roots = roots.includes(:sandbox_session).order(created_at: :desc).limit(limit).to_a
|
|
129
|
+
follow_ups = CodeSession.where(conversation_root_id: roots.map(&:id)).order(:turn_index, :id).group_by(&:conversation_root_id)
|
|
130
|
+
|
|
131
|
+
roots.map { |root| CodeConversationSerializer.summary(root, [ root, *follow_ups.fetch(root.id, []) ]) }
|
|
132
|
+
.sort_by { |row| row[:last_activity_at].to_s }.reverse
|
|
133
|
+
end
|
|
134
|
+
|
|
81
135
|
# What each stream opened with and what it finally answered, so a
|
|
82
136
|
# collapsed interaction row says what it was about — the same two lines a
|
|
83
137
|
# collapsed trace row shows. Two grouped queries rather than loading
|
|
@@ -110,7 +110,7 @@ module ActionAgent
|
|
|
110
110
|
code_sessions_supported: code_sessions_supported?,
|
|
111
111
|
codex_sessions_supported: codex_sessions_supported?,
|
|
112
112
|
pull_requests_supported: pull_requests_supported?,
|
|
113
|
-
|
|
113
|
+
**codex_status,
|
|
114
114
|
browser_modes: browser_modes,
|
|
115
115
|
**claude_code_status
|
|
116
116
|
}
|
|
@@ -359,12 +359,23 @@ module ActionAgent
|
|
|
359
359
|
[]
|
|
360
360
|
end
|
|
361
361
|
|
|
362
|
+
# Whether the configured backend runs Codex sessions, and runs them
|
|
363
|
+
# with the configured authentication (CodexAuth.backend_refusal).
|
|
362
364
|
def codex_sessions_supported?
|
|
363
|
-
SandboxOrchestrator.new
|
|
365
|
+
orchestrator = SandboxOrchestrator.new
|
|
366
|
+
orchestrator.supports_code_runner?("codex") && CodexAuth.backend_refusal(orchestrator).nil?
|
|
364
367
|
rescue StandardError, LoadError
|
|
365
368
|
false
|
|
366
369
|
end
|
|
367
370
|
|
|
371
|
+
# How Codex authenticates and whether the caller's can run: the mode,
|
|
372
|
+
# a boolean, and in :local_login mode this machine's login status.
|
|
373
|
+
# Never a credential.
|
|
374
|
+
def codex_status
|
|
375
|
+
status = CodexAuth.status(owned(ProviderKey))
|
|
376
|
+
{ codex_auth: status[:mode], codex_connected: status[:connected], codex_login: status[:login] }.compact
|
|
377
|
+
end
|
|
378
|
+
|
|
368
379
|
# Whether the configured backend can read a checkout's changes, which
|
|
369
380
|
# opening a pull request and downloading a patch both need.
|
|
370
381
|
def pull_requests_supported?
|
|
@@ -27,16 +27,19 @@ module ActionAgent
|
|
|
27
27
|
# service a service_name, narrowing `traces` only
|
|
28
28
|
# status "error" for failed traces only
|
|
29
29
|
def index
|
|
30
|
-
window =
|
|
31
|
-
|
|
30
|
+
window = clamped_param(:minutes, default: DEFAULT_WINDOW_MINUTES, min: 1, max: MAX_WINDOW_MINUTES)
|
|
31
|
+
to = Time.current
|
|
32
|
+
from = to - window.minutes
|
|
33
|
+
window_scope = agent_scope(traces_scope).where(timestamp: from...to)
|
|
32
34
|
|
|
33
35
|
scope = window_scope
|
|
34
36
|
scope = scope.for_agent(params[:agent]) if params[:agent].present?
|
|
35
37
|
scope = scope.for_service(params[:service]) if params[:service].present?
|
|
36
38
|
scope = scope.with_errors if params[:status] == "error"
|
|
37
39
|
|
|
38
|
-
limit =
|
|
39
|
-
traces = scope.recent.limit(limit)
|
|
40
|
+
limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 1000)
|
|
41
|
+
traces = scope.recent.limit(limit).to_a
|
|
42
|
+
report = TraceWindowReport.new(scope, from: from, to: to, bucket_seconds: integer_param(:bucket_seconds, default: 60)).as_json
|
|
40
43
|
|
|
41
44
|
render json: {
|
|
42
45
|
traces: traces.map { |trace| TelemetryTraceSerializer.summary(trace) },
|
|
@@ -45,8 +48,10 @@ module ActionAgent
|
|
|
45
48
|
# trace back to the agent it belongs to (AgentRegistrar attributes
|
|
46
49
|
# SDK-reported traces to an Agent record on ingest).
|
|
47
50
|
agent_ids: agent_ids_by_class(window_scope),
|
|
48
|
-
window_minutes: window
|
|
49
|
-
|
|
51
|
+
window_minutes: window,
|
|
52
|
+
returned_count: traces.size,
|
|
53
|
+
truncated: report[:total_count] > traces.size
|
|
54
|
+
}.merge(report)
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
# GET /api/traces/:id — accepts the record id or the OTel trace_id
|
|
@@ -60,15 +60,30 @@ module ActionAgent
|
|
|
60
60
|
finish(code_session, outcome.to_h, result_event, secrets)
|
|
61
61
|
verify(code_session)
|
|
62
62
|
resume_boot(code_session)
|
|
63
|
+
advance(code_session)
|
|
63
64
|
rescue StandardError => e
|
|
64
65
|
message = SecretScrubber.scrub(e.message.to_s, secrets || safe_secrets(code_session))
|
|
65
66
|
Rails.logger.error("Code session #{code_session_id} failed: #{message}")
|
|
66
67
|
fail!(code_session, message) if code_session
|
|
67
68
|
resume_boot(code_session) if code_session
|
|
69
|
+
advance(code_session) if code_session
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
# What a turn after the first says when the CLI no longer has the
|
|
73
|
+
# session it resumes.
|
|
74
|
+
RESUME_LOST = "The conversation could not be resumed: Claude Code no longer has its session " \
|
|
75
|
+
"(the sandbox restarted, or its Claude Code login changed). Start a new conversation."
|
|
76
|
+
|
|
70
77
|
private
|
|
71
78
|
|
|
79
|
+
# Starts the next queued turn of the conversation, now this one settled
|
|
80
|
+
# (CodeConversation).
|
|
81
|
+
def advance(code_session)
|
|
82
|
+
CodeConversation.advance!(code_session.reload)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Rails.logger.warn("Code session #{code_session.id}: the conversation's next turn was not started: #{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
# A project's boot fix resumes the boot it fixed (ProjectBootFix). Its
|
|
73
88
|
# failure is the boot's, not the session's, so it is logged and left on
|
|
74
89
|
# the project rather than raised.
|
|
@@ -112,7 +127,8 @@ module ActionAgent
|
|
|
112
127
|
else
|
|
113
128
|
code_session.assign_attributes(
|
|
114
129
|
status: :failed,
|
|
115
|
-
error_message: failure_message(result_event, outcome, secrets, label: code_session.runner == "codex" ? "Codex" : "Claude Code"
|
|
130
|
+
error_message: failure_message(result_event, outcome, secrets, label: code_session.runner == "codex" ? "Codex" : "Claude Code",
|
|
131
|
+
resumed: code_session.turn_index.to_i.positive?),
|
|
116
132
|
finished_at: Time.current
|
|
117
133
|
)
|
|
118
134
|
end
|
|
@@ -134,8 +150,9 @@ module ActionAgent
|
|
|
134
150
|
|
|
135
151
|
# What went wrong, in Claude Code's own words when it reported a failure
|
|
136
152
|
# and from the process otherwise.
|
|
137
|
-
def failure_message(result_event, outcome, secrets, label: "Claude Code")
|
|
153
|
+
def failure_message(result_event, outcome, secrets, label: "Claude Code", resumed: false)
|
|
138
154
|
message = reported_failure(result_event) || process_failure(result_event, outcome, label: label)
|
|
155
|
+
message = RESUME_LOST if resumed && message.match?(/no conversation found|session.{0,40}not found|thread.{0,40}not found/i)
|
|
139
156
|
SecretScrubber.scrub(message, secrets).truncate(MAX_ERROR_MESSAGE)
|
|
140
157
|
end
|
|
141
158
|
|
|
@@ -51,6 +51,7 @@ module ActionAgent
|
|
|
51
51
|
# Callbacks
|
|
52
52
|
before_validation :generate_slug, on: :create
|
|
53
53
|
before_validation :apply_conventional_schema_tools, on: :create
|
|
54
|
+
before_validation :require_approval_for_gained_side_effects, unless: :observed?
|
|
54
55
|
after_create :create_initial_version
|
|
55
56
|
after_update :create_version_on_config_change, if: :configuration_changed?
|
|
56
57
|
after_destroy :release_telemetry_traces, if: :observed?
|
|
@@ -83,6 +84,30 @@ module ActionAgent
|
|
|
83
84
|
github ruby rails aws gcp python typescript docker kubernetes
|
|
84
85
|
].freeze
|
|
85
86
|
|
|
87
|
+
# What each instruction set adds to the instructions a run executes
|
|
88
|
+
# under (#composed_instructions_for): the conventions of that stack, as a
|
|
89
|
+
# developer message the model can act on.
|
|
90
|
+
INSTRUCTION_SET_GUIDANCE = {
|
|
91
|
+
"github" => "Work the GitHub way: small focused commits with messages that say why, branches named for the " \
|
|
92
|
+
"change, pull requests that describe what changed and how it was verified, and issues referenced by number.",
|
|
93
|
+
"ruby" => "Write idiomatic Ruby: follow the project's RuboCop configuration, prefer small methods and plain " \
|
|
94
|
+
"objects, use keyword arguments for options, and add or update tests (Minitest or RSpec, as the project uses).",
|
|
95
|
+
"rails" => "Follow Rails conventions: fat models and thin controllers, strong parameters, migrations for every " \
|
|
96
|
+
"schema change, scopes and validations on the model, and bin/rails commands for generators, tests and tasks.",
|
|
97
|
+
"aws" => "For AWS, prefer least-privilege IAM, keep infrastructure in code (CloudFormation, CDK or Terraform), " \
|
|
98
|
+
"name the region explicitly, and never print or commit credentials.",
|
|
99
|
+
"gcp" => "For Google Cloud, prefer least-privilege service accounts, keep infrastructure in code (Terraform), " \
|
|
100
|
+
"name the project and region explicitly, and use Secret Manager rather than plain environment values.",
|
|
101
|
+
"python" => "Write idiomatic Python: PEP 8, type hints on public functions, the project's own environment and " \
|
|
102
|
+
"dependency tool, and pytest tests for new behaviour.",
|
|
103
|
+
"typescript" => "Write strict TypeScript: no implicit any, explicit types on exported APIs, the project's lint and " \
|
|
104
|
+
"format settings, and tests in the framework the project already uses.",
|
|
105
|
+
"docker" => "For Docker, keep images small and reproducible: pinned base image versions, multi-stage builds, a " \
|
|
106
|
+
"non-root user, and no secrets baked into layers.",
|
|
107
|
+
"kubernetes" => "For Kubernetes, write declarative manifests with resource requests and limits, readiness and " \
|
|
108
|
+
"liveness probes, and Secrets for credentials; apply changes namespace by namespace."
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
86
111
|
# Built-in tools/MCPs. Host-declared schema tools are offered alongside
|
|
87
112
|
# these — see .available_tools, which is what the editor and the APIs
|
|
88
113
|
# serialize. This constant stays the built-in set so existing references
|
|
@@ -156,6 +181,24 @@ module ActionAgent
|
|
|
156
181
|
self.tools = defaults if defaults.any?
|
|
157
182
|
end
|
|
158
183
|
|
|
184
|
+
# run_command, write_file, edit_file and slack_post_message wait for a
|
|
185
|
+
# person's approval unless the agent's approval list says otherwise
|
|
186
|
+
# (AgentToolbox::APPROVAL_BY_DEFAULT). They join the list when the agent
|
|
187
|
+
# gains their capability and this save brought no approval list of its
|
|
188
|
+
# own: an agent built from a template, a POST to the agents API with
|
|
189
|
+
# tools alone, a PATCH that enables a capability. The builder and the
|
|
190
|
+
# Tools tab send the list, so an Approval switch turned off holds.
|
|
191
|
+
def require_approval_for_gained_side_effects
|
|
192
|
+
return unless has_attribute?(:approval_required_tools)
|
|
193
|
+
return if attribute_came_from_user?("approval_required_tools")
|
|
194
|
+
|
|
195
|
+
gained = Array(tools).map(&:to_s) - Array(tools_in_database).map(&:to_s)
|
|
196
|
+
names = AgentToolbox.default_approvals(gained)
|
|
197
|
+
return if names.empty?
|
|
198
|
+
|
|
199
|
+
self[:approval_required_tools] = approval_required_tools | names
|
|
200
|
+
end
|
|
201
|
+
|
|
159
202
|
# The ActiveAgent class name this agent's runs are recorded under — the
|
|
160
203
|
# correlation key between platform Agent records and telemetry traces
|
|
161
204
|
# (TelemetryTrace#agent_class) and solid_agent contexts. An observed
|
|
@@ -229,9 +272,19 @@ module ActionAgent
|
|
|
229
272
|
# The system instructions an action executes under: named actions stack
|
|
230
273
|
# their prompt below the agent's base instructions; the default action
|
|
231
274
|
# uses the base instructions alone.
|
|
275
|
+
#
|
|
276
|
+
# The agent's instruction sets come last, as the conventions to work by.
|
|
232
277
|
def composed_instructions_for(action_name)
|
|
233
278
|
action = action_prompt_for(action_name)
|
|
234
|
-
[ instructions, action&.dig("prompt") ].map(&:presence).compact.join("\n\n").presence
|
|
279
|
+
[ instructions, action&.dig("prompt"), instruction_set_guidance ].map(&:presence).compact.join("\n\n").presence
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The guidance of the instruction sets this agent selected, or nil.
|
|
283
|
+
def instruction_set_guidance
|
|
284
|
+
lines = Array(instruction_sets).filter_map { |set| INSTRUCTION_SET_GUIDANCE[set.to_s] }
|
|
285
|
+
return nil if lines.empty?
|
|
286
|
+
|
|
287
|
+
"Conventions to follow:\n#{lines.map { |line| "- #{line}" }.join("\n")}"
|
|
235
288
|
end
|
|
236
289
|
|
|
237
290
|
# Returns the configuration as a hash for versioning
|