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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -0
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +61 -61
  5. data/app/controllers/action_agent/api/agents_controller.rb +23 -0
  6. data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
  7. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
  8. data/app/controllers/action_agent/api/evaluations_controller.rb +41 -3
  9. data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
  10. data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
  11. data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
  12. data/app/jobs/action_agent/code_session_job.rb +19 -2
  13. data/app/models/action_agent/agent.rb +54 -1
  14. data/app/models/action_agent/code_session.rb +80 -0
  15. data/app/models/action_agent/evaluation_run.rb +17 -2
  16. data/app/models/action_agent/provider_key.rb +5 -1
  17. data/app/queries/action_agent/trace_window_report.rb +60 -0
  18. data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
  19. data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
  20. data/app/services/action_agent/agent_execution_service.rb +37 -0
  21. data/app/services/action_agent/agent_tool_roster.rb +53 -18
  22. data/app/services/action_agent/agent_toolbox.rb +156 -3
  23. data/app/services/action_agent/browser_client_token.rb +86 -0
  24. data/app/services/action_agent/checkout_tools.rb +154 -0
  25. data/app/services/action_agent/code_conversation.rb +133 -0
  26. data/app/services/action_agent/codex_auth.rb +81 -0
  27. data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
  28. data/app/services/action_agent/diff_stat.rb +27 -0
  29. data/app/services/action_agent/evaluation_report_export.rb +175 -0
  30. data/app/services/action_agent/evaluation_report_import.rb +40 -17
  31. data/app/services/action_agent/evaluation_runner_service.rb +48 -12
  32. data/app/services/action_agent/local_sandbox_backend.rb +295 -30
  33. data/app/services/action_agent/mcp_catalog.rb +25 -0
  34. data/app/services/action_agent/mcp_client.rb +1 -0
  35. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
  36. data/app/services/action_agent/project_boot_fix.rb +10 -3
  37. data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
  38. data/app/services/action_agent/scenario_evaluation_runner.rb +1 -0
  39. data/app/services/action_agent/slack_tools.rb +101 -0
  40. data/config/routes.rb +10 -1
  41. data/lib/action_agent/engine.rb +6 -0
  42. data/lib/action_agent/version.rb +1 -1
  43. data/lib/action_agent.rb +35 -2
  44. data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
  45. data/lib/tasks/evaluations.rake +78 -0
  46. 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) : "api_key"
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
- return "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first" if sandbox.runtime_environment(runner: runner).blank?
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.external_run_id,
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 a judge model runs on, null
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: report.to_html(theme: params[:theme]).html_safe, layout: false
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
- codex_connected: owned(ProviderKey).where(provider: "codex").any? { |key| key.runtime_environment.present? },
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.supports_code_runner?("codex")
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 = params.fetch(:minutes, DEFAULT_WINDOW_MINUTES).to_i.clamp(1, MAX_WINDOW_MINUTES)
31
- window_scope = agent_scope(traces_scope).for_date_range(window.minutes.ago, Time.current)
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 = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 1000)
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