actionagent 1.7.2 → 1.8.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -6,6 +6,7 @@ module ActionAgent
6
6
  owned_by :user, :account
7
7
 
8
8
  belongs_to :agent_template, optional: true
9
+ has_many :code_sessions, dependent: :destroy
9
10
 
10
11
  # Session statuses
11
12
  enum :status, {
@@ -18,8 +19,14 @@ module ActionAgent
18
19
  failed: 6
19
20
  }
20
21
 
21
- # Sandbox types
22
- SANDBOX_TYPES = %w[playwright_mcp terminal research].freeze
22
+ # Sandbox types. +app_runtime+ boots a checkout of one of the owner's
23
+ # GitHub repositories (see GithubConnection) and exposes that app's own
24
+ # runtime, so agents and evaluations can use its tools.
25
+ SANDBOX_TYPES = %w[playwright_mcp terminal research app_runtime].freeze
26
+
27
+ # MCP server keys naming a checkout sandbox's app runtime, as an agent's
28
+ # mcp_servers lists them: "sandbox:<session_id>".
29
+ RUNTIME_SERVER_PREFIX = "sandbox:"
23
30
 
24
31
  # Free tier limits
25
32
  FREE_TIER_LIMITS = {
@@ -29,9 +36,21 @@ module ActionAgent
29
36
  session_duration_minutes: 15
30
37
  }.freeze
31
38
 
39
+ # How long a checkout sandbox lives. Booting one (clone, bundle install,
40
+ # db:prepare) can take minutes, and it is worked in for a while after —
41
+ # Claude Code sessions, agents using its tools — so the free tier's 15
42
+ # minutes would expire it about as soon as it was ready.
43
+ APP_RUNTIME_SESSION_DURATION = 2.hours
44
+
45
+ encrypts :runtime_mcp_token if ActionAgent.encrypt_credentials
46
+
32
47
  # Validations
33
48
  validates :session_id, presence: true, uniqueness: true
34
49
  validates :sandbox_type, inclusion: { in: SANDBOX_TYPES }
50
+ validates :repository, presence: true, if: :app_runtime?
51
+ validates :repository_ref, length: { maximum: 255 }, format: { without: /\A-|\s|\.\./, message: "is not a valid git ref" },
52
+ allow_blank: true
53
+ validate :repository_available, on: :create, if: :app_runtime?
35
54
 
36
55
  # Callbacks
37
56
  before_validation :generate_session_id, on: :create
@@ -51,6 +70,101 @@ module ActionAgent
51
70
  Array(mcp_servers).filter_map { |key| MCPCatalog.find(key) }
52
71
  end
53
72
 
73
+ def self.runtime_server_key?(key)
74
+ key.to_s.start_with?(RUNTIME_SERVER_PREFIX)
75
+ end
76
+
77
+ # The MCP catalog entry for a checkout sandbox's app runtime, looked up
78
+ # among +owner+'s sessions only. Nil unless the session is live and its
79
+ # backend reported an endpoint.
80
+ #
81
+ # @return [Hash, nil]
82
+ def self.runtime_server_entry(key, owner:)
83
+ return nil unless runtime_server_key?(key)
84
+
85
+ session = for_owner(owner).find_by(session_id: key.to_s.delete_prefix(RUNTIME_SERVER_PREFIX))
86
+ session&.runtime_server_entry
87
+ end
88
+
89
+ # The live runtimes in +scope+ (a relation already scoped to an owner) as
90
+ # MCP server listings: the catalog entry shape MCPCatalog serves, without
91
+ # the bearer token runtime_server_entry carries for the dispatcher.
92
+ #
93
+ # @return [Array<Hash>]
94
+ def self.runtime_server_listings(scope)
95
+ scope.active.by_type("app_runtime")
96
+ .where.not(runtime_mcp_url: [ nil, "" ])
97
+ .where("expires_at > ?", Time.current)
98
+ .recent.limit(20)
99
+ .filter_map(&:runtime_server_listing)
100
+ end
101
+
102
+ def app_runtime?
103
+ sandbox_type == "app_runtime"
104
+ end
105
+
106
+ def runtime_server_key
107
+ "#{RUNTIME_SERVER_PREFIX}#{session_id}"
108
+ end
109
+
110
+ # This session's app runtime as an MCP catalog entry — the shape
111
+ # MCPToolDispatcher reaches servers through.
112
+ def runtime_server_entry
113
+ return nil unless app_runtime? && active? && runtime_mcp_url.present?
114
+
115
+ {
116
+ key: runtime_server_key,
117
+ name: "#{repository}@#{repository_ref} (sandbox)",
118
+ description: "App runtime booted from a checkout of #{repository}",
119
+ transport: "streamable_http",
120
+ url: runtime_mcp_url,
121
+ headers: runtime_mcp_token.present? ? { "Authorization" => "Bearer #{runtime_mcp_token}" } : {}
122
+ }
123
+ end
124
+
125
+ # This runtime as a token-free MCP server listing (see
126
+ # runtime_server_listings), or nil when it is not live.
127
+ def runtime_server_listing
128
+ entry = runtime_server_entry or return nil
129
+
130
+ entry.except(:headers).merge(
131
+ command: nil, package: nil, categories: [ "runtime" ], docs_url: nil,
132
+ sandbox: false, sandbox_type: "app_runtime", first_party: false,
133
+ requires_credentials: [], tools: [], runtime: true
134
+ )
135
+ end
136
+
137
+ # What a sandbox backend clones for an app_runtime session: repository,
138
+ # ref, clone URL and the credentials to fetch it. Nil for any other
139
+ # sandbox type. Carries the owner's GitHub token, so it goes to the
140
+ # backend and never into a response.
141
+ #
142
+ # @return [Hash, nil]
143
+ def checkout_spec
144
+ return nil unless app_runtime?
145
+
146
+ github_connection&.checkout_spec(repository, ref: repository_ref)
147
+ end
148
+
149
+ # The GitHub connection whose selection this session's checkout comes
150
+ # from.
151
+ def github_connection
152
+ owners_record(GithubConnection)
153
+ end
154
+
155
+ # Environment the backend passes into an app_runtime checkout, so the
156
+ # booted app can run Claude Code sessions against it with the owner's
157
+ # connected credential (Settings -> Integrations). Empty when none is
158
+ # connected. Secret — for the backend, never a response.
159
+ #
160
+ # @return [Hash{String => String}]
161
+ def runtime_environment(runner: "claude_code")
162
+ return {} unless app_runtime?
163
+ return {} unless ProviderKey::CONNECTION_PROVIDERS.include?(runner)
164
+
165
+ owners_record(ProviderKey.where(provider: runner))&.runtime_environment || {}
166
+ end
167
+
54
168
  # Check if session is still valid
55
169
  def active?
56
170
  !expired? && !failed? && !completed? && expires_at > Time.current
@@ -95,28 +209,42 @@ module ActionAgent
95
209
 
96
210
  update!(status: :provisioning)
97
211
 
98
- # In development, run synchronously for immediate feedback
99
- if Rails.env.development? || Rails.env.test?
212
+ # A checkout always boots in the background: cloning and setting up a
213
+ # real app takes minutes, and a request must not wait on it, in
214
+ # development either. The client polls the session until it is ready
215
+ # (or failed). The other types are simulated in development and test,
216
+ # synchronously for immediate feedback.
217
+ if !app_runtime? && (Rails.env.development? || Rails.env.test?)
100
218
  SandboxProvisionJob.perform_now(id)
101
219
  else
102
220
  SandboxProvisionJob.perform_later(id)
103
221
  end
104
222
  end
105
223
 
106
- # Mark as ready with Cloud Run URL
107
- def mark_ready!(cloud_run_url:, cloud_run_job_id: nil)
108
- update!(
109
- status: :ready,
110
- cloud_run_url: cloud_run_url,
111
- cloud_run_job_id: cloud_run_job_id
112
- )
224
+ # Mark as ready with Cloud Run URL. A checkout sandbox's backend also
225
+ # reports the app runtime's MCP endpoint and the token it expects.
226
+ def mark_ready!(cloud_run_url:, cloud_run_job_id: nil, runtime_mcp_url: nil, runtime_mcp_token: nil)
227
+ attributes = { status: :ready, cloud_run_url: cloud_run_url, cloud_run_job_id: cloud_run_job_id }
228
+ attributes[:runtime_mcp_url] = runtime_mcp_url if runtime_mcp_url
229
+ attributes[:runtime_mcp_token] = runtime_mcp_token if runtime_mcp_token
230
+ update!(attributes)
113
231
  end
114
232
 
115
- # Expire the session
233
+ # Expire the session. Its runtime stops being reachable at once — the
234
+ # endpoint and its token are cleared, so no agent is handed a runtime
235
+ # that is going away — and the backend's resource is released by
236
+ # SandboxCleanupJob, which keeps the handle until that succeeds.
237
+ #
238
+ # Under the row lock, which reloads the row first: callers (DELETE, the
239
+ # reaper) loaded this copy earlier, and SandboxProvisionJob may have
240
+ # marked it ready since. Acting on the stale copy would neither clear the
241
+ # endpoint it recorded (nil -> nil writes nothing) nor see the handle to
242
+ # terminate, leaving the booted sandbox running.
116
243
  def expire!
117
- update!(status: :expired)
118
- # Cleanup Cloud Run resources
119
- SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present?
244
+ with_lock { update!(status: :expired, runtime_mcp_url: nil, runtime_mcp_token: nil) }
245
+ # A checkout with no handle may still have a boot behind it (its job
246
+ # died mid-boot, say); the cleanup job asks the backend for it.
247
+ SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present? || app_runtime?
120
248
  end
121
249
 
122
250
  # Summary for API responses
@@ -132,10 +260,30 @@ module ActionAgent
132
260
  expires_at: expires_at&.iso8601,
133
261
  created_at: created_at.iso8601,
134
262
  cloud_run_url: cloud_run_url,
135
- mcp_servers: Array(mcp_servers)
263
+ mcp_servers: Array(mcp_servers),
264
+ repository: repository,
265
+ repository_ref: repository_ref,
266
+ # The key an agent adds to its mcp_servers to use this runtime's
267
+ # tools; nil until the backend has reported the endpoint.
268
+ runtime_server_key: runtime_mcp_url.present? ? runtime_server_key : nil,
269
+ # Why provisioning failed. Scrubbed of the session's secrets when
270
+ # SandboxProvisionJob stored it.
271
+ error_message: error_summary
136
272
  }
137
273
  end
138
274
 
275
+ # A failed boot's message is a one-line reason followed by the tail of
276
+ # the failing step's log, and the log's last lines usually hold the
277
+ # actual error — so a long message keeps its head and its end.
278
+ ERROR_SUMMARY_HEAD = 300
279
+ ERROR_SUMMARY_TAIL = 1_700
280
+
281
+ def error_summary
282
+ return error_message if error_message.nil? || error_message.length <= ERROR_SUMMARY_HEAD + ERROR_SUMMARY_TAIL
283
+
284
+ "#{error_message[0, ERROR_SUMMARY_HEAD]}\n…\n#{error_message[-ERROR_SUMMARY_TAIL..]}"
285
+ end
286
+
139
287
  # Detailed info including runs
140
288
  def details
141
289
  summary.merge(
@@ -147,12 +295,41 @@ module ActionAgent
147
295
 
148
296
  private
149
297
 
298
+ # The owner's record in +scope+, found through that model's own owner
299
+ # column. GitHub connections and provider keys are account-owned before
300
+ # user-owned, the opposite of a session, so the session's #owner is not
301
+ # necessarily theirs.
302
+ def owners_record(scope)
303
+ scope = scope.all
304
+ case scope.klass.owner_association
305
+ when :account then account_id && scope.find_by(account_id: account_id)
306
+ when :user then user_id && scope.find_by(user_id: user_id)
307
+ else scope.first
308
+ end
309
+ end
310
+
311
+ def repository_available
312
+ return if repository.blank?
313
+
314
+ connection = github_connection
315
+ if connection.nil?
316
+ errors.add(:repository, "needs a GitHub connection (Settings -> Integrations)")
317
+ elsif (repo = connection.repository(repository))
318
+ # Canonical spelling, and the default branch unless a ref was asked for.
319
+ self.repository = repo["full_name"]
320
+ self.repository_ref = repository_ref.presence || repo["default_branch"]
321
+ else
322
+ errors.add(:repository, "is not one of the repositories selected in Settings -> Integrations")
323
+ end
324
+ end
325
+
150
326
  def generate_session_id
151
327
  self.session_id ||= SecureRandom.uuid
152
328
  end
153
329
 
154
330
  def set_expiration
155
- self.expires_at ||= FREE_TIER_LIMITS[:session_duration_minutes].minutes.from_now
331
+ duration = app_runtime? ? APP_RUNTIME_SESSION_DURATION : FREE_TIER_LIMITS[:session_duration_minutes].minutes
332
+ self.expires_at ||= duration.from_now
156
333
  self.max_runs ||= FREE_TIER_LIMITS[:max_runs]
157
334
  self.timeout_seconds ||= FREE_TIER_LIMITS[:timeout_seconds]
158
335
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to render evaluations and their runs for the dashboard's JSON API
5
+ # (Api::EvaluationsController) and its MCP facade (Api::MCPController), so
6
+ # both describe an evaluation the same way.
7
+ #
8
+ # A run's `number` is its position in its evaluation's history, oldest = 1,
9
+ # so a client can say "Run #3" and "vs #2".
10
+ module EvaluationSerializer
11
+ module_function
12
+
13
+ # Returns the evaluation with its latest run in full and just enough of
14
+ # the run before it to show movement ("+3 passed vs #2") without a
15
+ # request per evaluation. The headline run — the newest complete one —
16
+ # rides along in full as `headline_run` when a newer run is pending or
17
+ # failed, so a page can describe the finished run while showing the
18
+ # newer one beside it.
19
+ def evaluation(evaluation)
20
+ summary = summary(evaluation)
21
+ latest, previous = recent_runs(evaluation, 2)
22
+ headline = evaluation.standing_info.headline_run
23
+ headline = nil if headline.nil? || headline.id == latest&.id
24
+
25
+ summary.merge(
26
+ latest_run: latest ? run(latest, number: summary[:run_count]) : nil,
27
+ previous_run: previous ? run_summary(previous, number: summary[:run_count] - 1) : nil,
28
+ headline_run: headline ? run(headline, number: run_number(evaluation, headline)) : nil
29
+ )
30
+ end
31
+
32
+ # Returns the evaluation's configuration and run count, without its runs,
33
+ # and where it stands: `headline_run_id` (its newest complete run),
34
+ # `standing` against the agent's current version (EvaluationStanding),
35
+ # `archived_at`, and the headline run's passes `per_model`.
36
+ def summary(evaluation)
37
+ standing = evaluation.standing_info
38
+ {
39
+ id: evaluation.id,
40
+ name: evaluation.name,
41
+ agent: { id: evaluation.agent.id, name: evaluation.agent.name, slug: evaluation.agent.slug },
42
+ judge_kind: evaluation.judge_kind,
43
+ judge_model: evaluation.judge_model,
44
+ criteria: evaluation.criteria,
45
+ compare_models: evaluation.compare_models,
46
+ config: evaluation.config,
47
+ sample_size: evaluation.sample_size,
48
+ scenario_suite: evaluation.scenario_suite?,
49
+ scenario_count: evaluation.scenarios.size,
50
+ scenario_groups: evaluation.scenario_suite? ? evaluation.scenario_groups : [],
51
+ created_at: evaluation.created_at.iso8601,
52
+ # size reads a preloaded association and COUNTs otherwise.
53
+ run_count: evaluation.evaluation_runs.size,
54
+ headline_run_id: standing.headline_run&.id,
55
+ standing: standing.standing,
56
+ archived_at: evaluation.archived_at&.iso8601,
57
+ per_model: standing.per_model
58
+ }
59
+ end
60
+
61
+ # Returns the run summary plus its scores, selection, models, usage and
62
+ # error.
63
+ def run(run, number: nil)
64
+ run_summary(run, number: number).merge(
65
+ scores: scores(run),
66
+ selection: run.selection,
67
+ models: run.models,
68
+ usage: run.usage,
69
+ error_message: run.error_message
70
+ )
71
+ end
72
+
73
+ # Returns the run's scores with each scenario model summary carrying its
74
+ # "priced" count (EvaluationRun#model_summaries).
75
+ def scores(run)
76
+ summaries = run.model_summaries
77
+ summaries.empty? ? run.scores : run.scores.merge("_models" => summaries)
78
+ end
79
+
80
+ # Returns the run's status and headline numbers. `sandbox` is the
81
+ # checkout sandbox the run replayed against, if any: its session id and
82
+ # checkout, never its token. `agent_version` is the version of the
83
+ # agent the run scored, and `version_state` whether that is the agent's
84
+ # current version ("current", "earlier" or "unrecorded").
85
+ def run_summary(run, number: nil)
86
+ {
87
+ id: run.id,
88
+ number: number,
89
+ status: run.status,
90
+ average_score: average_score(run),
91
+ samples_evaluated: run.samples_evaluated,
92
+ samples_passed: run.samples_passed,
93
+ completed_at: run.completed_at&.iso8601,
94
+ created_at: run.created_at.iso8601,
95
+ sandbox: run.sandbox,
96
+ agent_version: run.agent_version_summary,
97
+ version_state: run.evaluation.standing_info.version_state(run)
98
+ }
99
+ end
100
+
101
+ # Returns up to +limit+ of the evaluation's runs, newest first. Sorts the
102
+ # preloaded association when one was loaded rather than issuing one
103
+ # ORDER BY query per evaluation.
104
+ def recent_runs(evaluation, limit)
105
+ runs = evaluation.evaluation_runs
106
+ if runs.loaded?
107
+ runs.sort_by { |run| [ run.created_at, run.id ] }.reverse.first(limit)
108
+ else
109
+ runs.order(created_at: :desc, id: :desc).limit(limit).to_a
110
+ end
111
+ end
112
+
113
+ # Returns the run's position in its evaluation's history, oldest = 1 —
114
+ # counted in the preloaded association when one was loaded.
115
+ def run_number(evaluation, run)
116
+ runs = evaluation.evaluation_runs
117
+ if runs.loaded?
118
+ runs.count { |other| other.created_at < run.created_at || (other.created_at == run.created_at && other.id <= run.id) }
119
+ else
120
+ runs.where("created_at < ? OR (created_at = ? AND id <= ?)", run.created_at, run.created_at, run.id).count
121
+ end
122
+ end
123
+
124
+ # Returns the run's fix items, or [] when they cannot be built. They are
125
+ # derived from every persisted result's diagnosis, which older runs
126
+ # recorded in earlier shapes, so a run they fail for still serves its
127
+ # results. Without +links+ the item paths are relative to the mount.
128
+ def fix_items(run, links: nil)
129
+ links ? run.fix_items(links: links) : run.fix_items
130
+ rescue StandardError => e
131
+ Rails.logger.warn(
132
+ "[ActionAgent] evaluation run #{run.id} fix_items failed: #{e.class}: #{e.message}"
133
+ )
134
+ []
135
+ end
136
+
137
+ # Returns the run's average score, or nil when its scores payload cannot
138
+ # be averaged, so one bad run degrades its own headline number rather
139
+ # than failing a whole listing.
140
+ def average_score(run)
141
+ run.average_score
142
+ rescue StandardError => e
143
+ Rails.logger.warn(
144
+ "[ActionAgent] evaluation run #{run.id} average_score failed: #{e.class}: #{e.message}"
145
+ )
146
+ nil
147
+ end
148
+ end
149
+ end
@@ -21,7 +21,16 @@ module ActionAgent
21
21
  @trace = trace
22
22
  end
23
23
 
24
+ def self.row(trace)
25
+ new(trace).row
26
+ end
27
+
24
28
  def summary
29
+ row.merge(spans: serialized_spans)
30
+ end
31
+
32
+ # The summary without its spans: one line of a trace listing.
33
+ def row
25
34
  {
26
35
  id: @trace.id,
27
36
  trace_id: @trace.trace_id,
@@ -48,8 +57,7 @@ module ActionAgent
48
57
  model: @trace.model,
49
58
  input_tokens: @trace.total_input_tokens,
50
59
  output_tokens: @trace.total_output_tokens
51
- ),
52
- spans: serialized_spans
60
+ )
53
61
  }
54
62
  end
55
63
 
@@ -113,6 +113,7 @@ module ActionAgent
113
113
 
114
114
  def call
115
115
  @agent_record.ensure_executable!
116
+ mcp_dispatcher.ensure_extra_servers_live!
116
117
  root_span = @root_span = build_root_span
117
118
  record_prompt_span(root_span)
118
119
  llm_span = root_span.add_span(
@@ -415,9 +416,10 @@ module ActionAgent
415
416
  # its reply, so agents can delegate to each other as a tool call. The
416
417
  # sub-run is a real AgentRun with its own trace.
417
418
  # One dispatcher per run, so every tool call shares the MCP sessions the
418
- # first call opens.
419
+ # first call opens. A run given a checkout sandbox (an evaluation or a
420
+ # runner run against it) reaches that runtime too.
419
421
  def mcp_dispatcher
420
- @mcp_dispatcher ||= MCPToolDispatcher.new(@agent_record)
422
+ @mcp_dispatcher ||= MCPToolDispatcher.new(@agent_record, extra_server_keys: [ @run.try(:sandbox_server_key) ].compact)
421
423
  end
422
424
 
423
425
  # Splits the offered schemas the way `tool_schemas` assembles them, so the
@@ -32,7 +32,7 @@ module ActionAgent
32
32
  avg_durations = windowed.where.not(duration_ms: nil).group(:agent_id).average(:duration_ms)
33
33
  token_sums = windowed.group(:agent_id).sum("COALESCE(total_tokens, 0)")
34
34
  last_runs = AgentRun.where(agent_id: ids).group(:agent_id).maximum(:created_at)
35
- eval_runs = latest_evaluation_runs(ids)
35
+ evals = evaluation_tiles(ids)
36
36
 
37
37
  trace_stats = telemetry_stats(ids, window_start)
38
38
  trace_last = unclaimed_traces(ids, nil).group(:agent_id).maximum(:timestamp)
@@ -44,7 +44,7 @@ module ActionAgent
44
44
  traced = stats[:count].to_i
45
45
  total = runs + traced
46
46
  succeeded = completed_counts[id].to_i + stats[:ok].to_i
47
- eval_run = eval_runs[id]
47
+ eval_tile = evals[id] || {}
48
48
 
49
49
  {
50
50
  window_days: (WINDOW / 1.day).to_i,
@@ -55,9 +55,13 @@ module ActionAgent
55
55
  avg_duration_ms: blended_duration(avg_durations[id], runs, stats[:avg_duration], traced),
56
56
  tokens: token_sums[id].to_i + stats[:tokens].to_i,
57
57
  cost: costs[id],
58
- eval_score: eval_run&.average_score,
59
- eval_samples_passed: eval_run&.samples_passed,
60
- eval_samples_evaluated: eval_run&.samples_evaluated,
58
+ # The evaluation tile: the pass rate pooled over the headline runs
59
+ # of the agent's evaluations that stand against its current version.
60
+ eval_score: eval_tile[:score],
61
+ eval_samples_passed: eval_tile[:passed],
62
+ eval_samples_evaluated: eval_tile[:evaluated],
63
+ eval_runs: eval_tile[:runs],
64
+ eval_not_counted: eval_tile[:not_counted],
61
65
  last_run_at: [ last_runs[id], trace_last[id] ].compact.max&.iso8601
62
66
  }
63
67
  end
@@ -170,22 +174,47 @@ module ActionAgent
170
174
  end
171
175
  private_class_method :blended_duration
172
176
 
173
- # Latest complete evaluation run per agent. DISTINCT ON would do this in
174
- # one pass on PostgreSQL, but it has no portable equivalent, so the
175
- # highest id per agent (runs are only ever appended) is selected first
176
- # and those rows fetched by id.
177
- def self.latest_evaluation_runs(agent_ids)
178
- evaluations = Evaluation.table_name
179
- runs = EvaluationRun.table_name
180
-
181
- newest = EvaluationRun.complete.joins(:evaluation)
182
- .where(evaluations => { agent_id: agent_ids })
183
- .group("#{evaluations}.agent_id")
184
- .pluck(Arel.sql("#{evaluations}.agent_id"), Arel.sql("MAX(#{runs}.id)"))
185
-
186
- by_run_id = newest.to_h { |agent_id, run_id| [ run_id, agent_id ] }
187
- EvaluationRun.where(id: by_run_id.keys).index_by { |run| by_run_id[run.id] }
177
+ # The evaluation tile per agent: passes pooled over the headline runs
178
+ # (newest complete run) of its evaluations that are current against the
179
+ # agent's version or unrecorded, as the Evaluations page pools them —
180
+ # never a stale suite last run against older code, nor an archived one
181
+ # — so the tile is the pass rate of the agent as it is now, not a mean
182
+ # of criterion scores over whatever ran last.
183
+ #
184
+ # { agent_id => { score:, passed:, evaluated:, runs:, not_counted: } }
185
+ #
186
+ # `score` is the pooled pass rate 0..1 (nil with nothing to count),
187
+ # `runs` the headline runs pooled and `not_counted` the evaluations
188
+ # left out as stale or archived. The newest complete run per evaluation
189
+ # is selected by id first (runs are only ever appended) and those rows
190
+ # fetched by id, which runs the same on every database.
191
+ def self.evaluation_tiles(agent_ids)
192
+ evaluations = Evaluation.where(agent_id: agent_ids).includes(:agent).to_a
193
+ return {} if evaluations.empty?
194
+
195
+ newest = EvaluationRun.complete.where(evaluation_id: evaluations.map(&:id)).group(:evaluation_id).maximum(:id)
196
+ runs = EvaluationRun.where(id: newest.values).includes(:agent_version).index_by(&:evaluation_id)
197
+ EvaluationStanding.preload(evaluations)
198
+
199
+ evaluations.group_by(&:agent_id).to_h do |agent_id, mine|
200
+ tile = { score: nil, passed: 0, evaluated: 0, runs: 0, not_counted: 0 }
201
+ mine.each do |evaluation|
202
+ run = runs[evaluation.id]
203
+ next unless run
204
+
205
+ unless evaluation.standing_info.with_headline(run).counted?
206
+ tile[:not_counted] += 1
207
+ next
208
+ end
209
+
210
+ tile[:runs] += 1
211
+ tile[:passed] += run.samples_passed.to_i
212
+ tile[:evaluated] += run.samples_evaluated.to_i
213
+ end
214
+ tile[:score] = (tile[:passed].to_f / tile[:evaluated]).round(3) if tile[:evaluated].positive?
215
+ [ agent_id, tile ]
216
+ end
188
217
  end
189
- private_class_method :latest_evaluation_runs
218
+ private_class_method :evaluation_tiles
190
219
  end
191
220
  end
@@ -25,7 +25,9 @@ module ActionAgent
25
25
  # offers every agent. These are the roster: +agent.tools+ is what
26
26
  # AgentToolbox turns into function schemas at generation time.
27
27
  # * **MCP** — never stored on the roster. Computed from the services the
28
- # agent enables, which is where they are edited.
28
+ # agent enables, which is where they are edited. The services are the
29
+ # catalog plus the live checkout sandbox runtimes the caller hands in
30
+ # (+runtimes:+), which an agent enables under their "sandbox:<id>" keys.
29
31
  #
30
32
  # Enablement reads the agent's own configuration: +tools+ for the dashboard
31
33
  # capabilities and the schema tools, +mcp_servers+ for services and their
@@ -40,13 +42,16 @@ module ActionAgent
40
42
  # workspace already talks to, then the rest of the catalog.
41
43
  STATUS_RANK = { "active" => 0, "configured" => 1, "available" => 2, "idle" => 3 }.freeze
42
44
 
43
- attr_reader :agent, :discovery
45
+ attr_reader :agent, :discovery, :runtimes
44
46
 
45
47
  # @param agent [ActionAgent::Agent] the agent being edited
46
48
  # @param traces [ActiveRecord::Relation] the traces the caller may read
47
49
  # @param hours [Integer] the window the usage columns are scoped to
48
- def initialize(agent:, traces:, hours: ToolDiscovery::DEFAULT_WINDOW_HOURS)
50
+ # @param runtimes [Array<Hash>] live checkout sandbox runtimes this agent
51
+ # can be given, as SandboxSession.runtime_server_listings returns them
52
+ def initialize(agent:, traces:, hours: ToolDiscovery::DEFAULT_WINDOW_HOURS, runtimes: [])
49
53
  @agent = agent
54
+ @runtimes = Array(runtimes).index_by { |runtime| runtime[:key] }
50
55
  @discovery = ToolDiscovery.new(
51
56
  traces: agent.telemetry_traces(traces),
52
57
  agents: Agent.where(id: agent.id),
@@ -89,10 +94,10 @@ module ActionAgent
89
94
  end
90
95
  end
91
96
 
92
- # The catalog, plus anything this agent's traffic or configuration names
93
- # that the catalog doesn't describe.
97
+ # The catalog and the live runtimes, plus anything this agent's traffic or
98
+ # configuration names that neither describes.
94
99
  def service_keys
95
- (MCPCatalog.keys + detected_by_server.keys + configured_servers.keys).uniq
100
+ (MCPCatalog.keys + runtimes.keys + detected_by_server.keys + configured_servers.keys).uniq
96
101
  end
97
102
 
98
103
  def detected_by_server
@@ -100,7 +105,8 @@ module ActionAgent
100
105
  end
101
106
 
102
107
  def service_row(key)
103
- catalog = MCPCatalog.find(key)
108
+ # A live runtime reads as a known service; its listing carries no token.
109
+ catalog = MCPCatalog.find(key) || runtimes[key]
104
110
  used = detected_by_server[key] || []
105
111
  calls = used.sum { |tool| tool[:calls] }
106
112
  enabled = configured_servers.key?(key)
@@ -112,6 +118,7 @@ module ActionAgent
112
118
  docs_url: catalog&.dig(:docs_url),
113
119
  first_party: catalog ? catalog[:first_party] : false,
114
120
  known: !catalog.nil?,
121
+ runtime: SandboxSession.runtime_server_key?(key),
115
122
  transport: transport_label(catalog),
116
123
  status: service_status(calls, enabled, catalog),
117
124
  enabled: enabled,
@@ -125,12 +132,16 @@ module ActionAgent
125
132
  # How to reach the server, in the one line the expanded panel shows:
126
133
  # "Streamable HTTP · <url>" for the ones the dashboard can call,
127
134
  # "sandbox · <command>" for the ones it can start, "stdio · <command>"
128
- # for the rest.
135
+ # for the rest. Every transport MCPToolDispatcher calls reads the same,
136
+ # because MCPClient reaches all of them over Streamable HTTP — a checkout
137
+ # runtime ("streamable_http") included.
129
138
  def transport_label(catalog)
130
139
  return nil if catalog.nil?
131
140
 
132
141
  transport = catalog[:transport].to_s
133
- return [ "Streamable HTTP", catalog[:url] ].compact.join(" · ") if transport == "http"
142
+ if transport.in?(MCPToolDispatcher::HTTP_TRANSPORTS)
143
+ return [ "Streamable HTTP", catalog[:url].presence ].compact.join(" · ")
144
+ end
134
145
 
135
146
  prefix = catalog[:sandbox] ? "sandbox" : transport.presence
136
147
  [ prefix, catalog[:command] ].compact.join(" · ").presence