actionagent 1.9.1 → 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 (50) 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 +56 -6
  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/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +2 -2
  13. data/app/jobs/action_agent/code_session_job.rb +19 -2
  14. data/app/jobs/action_agent/evaluation_judge_job.rb +39 -0
  15. data/app/jobs/action_agent/evaluation_replay_job.rb +28 -0
  16. data/app/models/action_agent/agent.rb +54 -1
  17. data/app/models/action_agent/code_session.rb +80 -0
  18. data/app/models/action_agent/evaluation_run.rb +37 -5
  19. data/app/models/action_agent/provider_key.rb +5 -1
  20. data/app/queries/action_agent/trace_window_report.rb +60 -0
  21. data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
  22. data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
  23. data/app/services/action_agent/agent_execution_service.rb +37 -0
  24. data/app/services/action_agent/agent_tool_roster.rb +53 -18
  25. data/app/services/action_agent/agent_toolbox.rb +156 -3
  26. data/app/services/action_agent/browser_client_token.rb +86 -0
  27. data/app/services/action_agent/checkout_tools.rb +154 -0
  28. data/app/services/action_agent/code_conversation.rb +133 -0
  29. data/app/services/action_agent/codex_auth.rb +81 -0
  30. data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
  31. data/app/services/action_agent/diff_stat.rb +27 -0
  32. data/app/services/action_agent/evaluation_fix_comparison.rb +4 -1
  33. data/app/services/action_agent/evaluation_report_export.rb +175 -0
  34. data/app/services/action_agent/evaluation_report_import.rb +47 -19
  35. data/app/services/action_agent/evaluation_runner_service.rb +106 -14
  36. data/app/services/action_agent/local_sandbox_backend.rb +295 -30
  37. data/app/services/action_agent/mcp_catalog.rb +25 -0
  38. data/app/services/action_agent/mcp_client.rb +1 -0
  39. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
  40. data/app/services/action_agent/project_boot_fix.rb +10 -3
  41. data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
  42. data/app/services/action_agent/scenario_evaluation_runner.rb +381 -3
  43. data/app/services/action_agent/slack_tools.rb +101 -0
  44. data/config/routes.rb +10 -1
  45. data/lib/action_agent/engine.rb +6 -0
  46. data/lib/action_agent/version.rb +1 -1
  47. data/lib/action_agent.rb +50 -2
  48. data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
  49. data/lib/tasks/evaluations.rake +78 -0
  50. metadata +15 -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
@@ -82,18 +82,30 @@ module ActionAgent
82
82
  # `evaluation: { archived: true | false }` archives the evaluation —
83
83
  # it keeps its runs but leaves the index, the pooled pass rate and the
84
84
  # agent's scorecard — or brings it back. A new run brings it back too.
85
+ # `evaluation: { judge_model: "…" }` pins the model the judge runs on
86
+ # from the next run; blank unpins it, back to the provider's default.
85
87
  def update
86
88
  evaluation = evaluations_scope.find(params[:id])
87
- archived = params.require(:evaluation)[:archived]
88
- if archived.nil?
89
+ attributes = params.require(:evaluation)
90
+ archived = attributes[:archived]
91
+ pin_judge = attributes.key?(:judge_model)
92
+ if archived.nil? && !pin_judge
89
93
  return render json: { errors: [ "evaluation.archived must be true or false" ] }, status: :unprocessable_entity
90
94
  end
91
95
 
92
- ActiveModel::Type::Boolean.new.cast(archived) ? evaluation.archive! : evaluation.unarchive!
96
+ judge_model = attributes[:judge_model].to_s.strip.presence
97
+ if judge_model && judge_model.length > MAX_JUDGE_MODEL
98
+ return render json: { errors: [ "evaluation.judge_model is longer than #{MAX_JUDGE_MODEL} characters" ] }, status: :unprocessable_entity
99
+ end
100
+
101
+ evaluation.update!(judge_model: judge_model) if pin_judge
102
+ ActiveModel::Type::Boolean.new.cast(archived) ? evaluation.archive! : evaluation.unarchive! unless archived.nil?
93
103
 
94
104
  render json: { evaluation: serialize(evaluation.reload) }
95
105
  end
96
106
 
107
+ MAX_JUDGE_MODEL = 200
108
+
97
109
  # Runs listed per evaluation on GET /api/evaluations/:id. The rest of
98
110
  # the history stays reachable by run id; `run_count` says how long it is.
99
111
  RUN_HISTORY_LIMIT = 20
@@ -243,14 +255,44 @@ module ActionAgent
243
255
  # the page follows the viewer's own preference. The page is served
244
256
  # outside the React app, so its fix item actions link at the absolute
245
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`.
246
270
  def run_report
247
271
  evaluation = evaluations_scope.find(params[:id])
248
272
  run = evaluation.evaluation_runs.find(params[:run_id])
249
273
  raise ActiveRecord::RecordNotFound unless evaluation.scenario_suite?
250
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
+
251
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?
252
292
 
253
- 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
254
296
  end
255
297
 
256
298
  # DELETE /api/evaluations/:id
@@ -261,6 +303,14 @@ module ActionAgent
261
303
 
262
304
  private
263
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
+
264
314
  def include_archived?
265
315
  ActiveModel::Type::Boolean.new.cast(params[:archived]) == true
266
316
  end
@@ -411,7 +461,7 @@ module ActionAgent
411
461
 
412
462
  def run_number(evaluation, run) = EvaluationSerializer.run_number(evaluation, run)
413
463
 
414
- def safe_fix_items(run) = EvaluationSerializer.fix_items(run)
464
+ def safe_fix_items(run) = EvaluationSerializer.fix_items(run, markdown: true)
415
465
  end
416
466
  end
417
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
@@ -270,7 +270,7 @@ module ActionAgent
270
270
  properties: {
271
271
  evaluation_id: { type: "integer", description: "The evaluation's id" },
272
272
  run_id: { type: "integer", description: "The run's id; the latest run when omitted" },
273
- failed_only: { type: "boolean", description: "Only results that did not pass" },
273
+ failed_only: { type: "boolean", description: "Only results that failed or errored (a running run's pending results are neither)" },
274
274
  limit: { type: "integer", description: "At most this many results (default #{RESULT_LIMIT}, max #{MAX_RESULT_LIMIT})" }
275
275
  },
276
276
  required: [ "evaluation_id" ]
@@ -737,7 +737,7 @@ module ActionAgent
737
737
 
738
738
  results = run.scenario_results.includes(:scenario, :agent_run).joins(:scenario)
739
739
  .order(EvaluationScenario.arel_table[:position], EvaluationScenario.arel_table[:id], :model)
740
- results = results.where.not(status: :passed) if boolean_argument(:failed_only)
740
+ results = results.where(status: %i[failed errored]) if boolean_argument(:failed_only)
741
741
  total = results.count
742
742
  page = results.limit(limit).to_a
743
743
  recorded = recorded_trace_ids(page.filter_map { |result| result.agent_run&.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
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Scores, diagnoses and stores one pending result of a fanned-out
5
+ # evaluation run (see EvaluationReplayJob). The job that scores the run's
6
+ # last pending result completes the run.
7
+ #
8
+ # A failure while scoring is stored on the result as an error rather than
9
+ # raised, so the run still completes.
10
+ class EvaluationJudgeJob < ApplicationJob
11
+ queue_as :agents
12
+
13
+ discard_on ActiveRecord::RecordNotFound
14
+
15
+ def perform(result_id, selection = {}, run_id = nil)
16
+ row = EvaluationScenarioResult.find_by(id: result_id)
17
+ return finish_without_result(run_id, selection) unless row
18
+
19
+ run = row.evaluation_run
20
+ begin
21
+ ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).judge_result!(row)
22
+ rescue StandardError => e
23
+ Rails.logger.warn("[ActionAgent] evaluation run #{run.id}: scoring result #{result_id} failed: #{e.class}: #{e.message}")
24
+ ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).fail_judging!(row.reload, e)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # A result destroyed before it was scored went with its scenario, removed
31
+ # from the suite during the run (EvaluationScenario destroys its results).
32
+ # It may have been the last result the run waited for, so the run's
33
+ # completion is checked without it.
34
+ def finish_without_result(run_id, selection)
35
+ run = run_id && EvaluationRun.find_by(id: run_id)
36
+ ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).finish_fan_out!(run) if run
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Replays one scenario under one model for a fanned-out evaluation run
5
+ # (ActionAgent.scenario_evaluation_fan_out) and stores the answer as a
6
+ # pending result; EvaluationJudgeJob scores it. One is enqueued per
7
+ # scenario x model, so the run is as parallel as the :agents queue.
8
+ #
9
+ # A failure outside the replay is stored as the pair's errored result
10
+ # rather than raised, so the run still completes; a scenario removed from
11
+ # the suite during the run is left out of it.
12
+ class EvaluationReplayJob < ApplicationJob
13
+ queue_as :agents
14
+
15
+ discard_on ActiveRecord::RecordNotFound
16
+
17
+ def perform(run_id, scenario_id, spec, selection = {})
18
+ run = EvaluationRun.find(run_id)
19
+ runner = ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run)
20
+ begin
21
+ runner.replay_scenario!(run, scenario_id, spec)
22
+ rescue StandardError => e
23
+ Rails.logger.warn("[ActionAgent] evaluation run #{run_id}: replay of scenario #{scenario_id} failed: #{e.class}: #{e.message}")
24
+ ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).fail_result!(run.reload, scenario_id, spec, e)
25
+ end
26
+ end
27
+ end
28
+ end