actionagent 1.9.1 → 1.9.2

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.
@@ -17,6 +17,9 @@ module ActionAgent
17
17
  # "_models" — per model: pass rate, mean score, latency, tokens, cost, fault counts
18
18
  # "_recommendations" — faults grouped across scenarios with the fix each calls for
19
19
  # "_verdict" — the best model and why (judge-written when a judge is available)
20
+ # "_comparison" — when comparing models: each model's pass rate with its
21
+ # interval, every pair's difference and whether it is
22
+ # decisive (ActiveAgent::Evals::Comparison)
20
23
  # "_selection" — the scenarios and models this run covered, and
21
24
  # the checkout sandbox it replayed against, if any
22
25
  #
@@ -87,6 +90,9 @@ module ActionAgent
87
90
 
88
91
  ensure_browser_live!
89
92
 
93
+ adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(@evaluation)
94
+ return fan_out!(run, scenarios, specs) if fan_out?(adapter)
95
+
90
96
  records = scenarios.index_by(&:key)
91
97
  tasks = scenarios.map { |scenario| Evals::Scenario.from_hash(scenario.as_json_summary) }
92
98
  expected = tasks.product(specs).map { |task, spec| [ task.key, spec.label ] }
@@ -102,7 +108,6 @@ module ActionAgent
102
108
  persist(run, records.fetch(result.scenario.key), result)
103
109
  persisted << pair
104
110
  end
105
- adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(@evaluation)
106
111
  report = if adapter
107
112
  raise ArgumentError, "scenario evaluation adapter must be callable" unless adapter.respond_to?(:call)
108
113
 
@@ -142,8 +147,361 @@ module ActionAgent
142
147
  stop_started_browser
143
148
  end
144
149
 
150
+ # --- fan-out (EvaluationReplayJob, EvaluationJudgeJob) -----------------
151
+ #
152
+ # With ActionAgent.scenario_evaluation_fan_out on, #call enqueues one
153
+ # EvaluationReplayJob per scenario x model and returns the run still
154
+ # running. Each replay is stored as a pending result and scored by its
155
+ # own EvaluationJudgeJob, so the agent's next replay need not wait for
156
+ # the judge. The job that scores the last pending result completes the
157
+ # run with the same scores the sequential path writes.
158
+ #
159
+ # The agent's tool roster is discovered once, before the replays, and
160
+ # kept with the run's bookkeeping (scores["_fan_out"]) for every job to
161
+ # read, as the sequential path resolves it once per run: a diagnosis
162
+ # names the same tools whichever job wrote it, and the run's discovery
163
+ # warning does not depend on which job completed the run.
164
+
165
+ # Replays one scenario under one model and stores the answer as a pending
166
+ # result, then enqueues its scoring. A result already stored for the
167
+ # pair (a retried job) is not replayed again.
168
+ def replay_scenario!(run, scenario_id, spec_value)
169
+ return unless run.running?
170
+
171
+ scenario = @evaluation.scenarios.find(scenario_id)
172
+ spec = model_spec_from(spec_value)
173
+ row = stored_result(run, scenario, spec)
174
+ unless row
175
+ task = Evals::Scenario.from_hash(scenario.as_json_summary)
176
+ replay = fan_out_replay(task, spec)
177
+ row = run.with_lock { stored_result(run, scenario, spec) || store_replay(run, scenario, task, spec, replay) }
178
+ end
179
+ EvaluationJudgeJob.perform_later(row.id, @selection.to_h, run.id) if row.pending?
180
+ row
181
+ end
182
+
183
+ # Scores, diagnoses and stores one pending result, then completes the run
184
+ # when it was the last.
185
+ #
186
+ # A job delivered again for a result that is already scored, or that a
187
+ # concurrent delivery has claimed, scores nothing. It still checks
188
+ # whether the run is complete: the earlier delivery may have stored the
189
+ # last result and stopped before completing the run.
190
+ def judge_result!(row)
191
+ run = row.evaluation_run
192
+ return unless run.running?
193
+ return finish_fan_out!(run) unless row.pending? && claim_result!(row)
194
+
195
+ spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do
196
+ Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model)
197
+ end
198
+ result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row))
199
+ row.update!(
200
+ status: result.status,
201
+ score: result.score,
202
+ scores: result.scores,
203
+ fault: result.fault,
204
+ recommendation: result.recommendation,
205
+ diagnosis: (result.diagnosis || {}).merge(row.diagnosis.to_h.slice("_replay_metadata", "_scenario_snapshot"))
206
+ .merge("_judge_usage" => judge_usage).compact
207
+ )
208
+ # The result now holds what its scoring cost; whatever this runner asks
209
+ # the judge from here on (the verdict, when it completes the run) is the
210
+ # run's own.
211
+ @judge_usage = nil
212
+ finish_fan_out!(run)
213
+ end
214
+
215
+ # Stores +error+ as the result of a pair whose job failed outside the
216
+ # replay (which records its own errors), so the run still completes. A
217
+ # scenario removed from the suite during the run has no result to
218
+ # store; the run completes without it (see #finish_fan_out!).
219
+ def fail_result!(run, scenario_id, spec_value, error)
220
+ return unless run.running?
221
+
222
+ scenario = @evaluation.scenarios.find_by(id: scenario_id)
223
+ if scenario
224
+ spec = model_spec_from(spec_value)
225
+ run.with_lock do
226
+ row = stored_result(run, scenario, spec)
227
+ task = Evals::Scenario.from_hash(scenario.as_json_summary)
228
+ row ||= store_replay(run, scenario, task, spec, Evals::Replay.failed(error))
229
+ row.update!(status: :errored, error_message: row.error_message.presence || error_text(error)) if row.pending?
230
+ end
231
+ end
232
+ finish_fan_out!(run)
233
+ end
234
+
235
+ # Marks a pending result errored when its scoring job failed, so the run
236
+ # still completes.
237
+ def fail_judging!(row, error)
238
+ run = row.evaluation_run
239
+ row.update!(status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending?
240
+ finish_fan_out!(run)
241
+ end
242
+
243
+ # Completes the run once every selected scenario x model has a scored
244
+ # result, with the scores, verdict and judge usage a sequential run
245
+ # writes. A scenario removed from the suite during the run is no longer
246
+ # waited for (its results went with it), so the run completes without it.
247
+ #
248
+ # Nothing is computed under a lock. The verdict is a judge call, and a
249
+ # lock on the run is, on SQLite, the write lock on the whole database:
250
+ # held for the call, it would stall every other writer. The lock covers
251
+ # only the check that the run is still running and the one write that
252
+ # completes it. Of two jobs that both find the run finished, one
253
+ # completes it and the other's work is dropped, at the cost of a second
254
+ # verdict call in that rare tie.
255
+ def finish_fan_out!(run)
256
+ run.reload
257
+ return unless run.running?
258
+
259
+ specs = fan_out_specs(run)
260
+ rows = run.scenario_results.includes(:agent_run).to_a
261
+ return if rows.size < expected_results(run, specs) || rows.any?(&:pending?)
262
+
263
+ attributes = begin
264
+ completion_attributes(run, rows, specs)
265
+ rescue StandardError => e
266
+ { status: :failed, error_message: e.message, completed_at: Time.current }
267
+ end
268
+ run.with_lock do
269
+ run.update!(attributes) if run.running?
270
+ end
271
+ end
272
+
145
273
  private
146
274
 
275
+ def fan_out?(adapter)
276
+ ActionAgent.scenario_evaluation_fan_out == true && adapter.nil? && !browser_requested? &&
277
+ Evals::Runner.instance_method(:initialize).parameters.any? { |_type, name| name == :refine_gate }
278
+ end
279
+
280
+ def fan_out!(run, scenarios, specs)
281
+ refuse_duplicate_specs!(specs)
282
+ ensure_judge_defined_kpis! if @evaluation.judge_defined?
283
+ # Discovered here, before any lock is taken and before a sandbox the
284
+ # run replays against can expire; the jobs read it from the run.
285
+ roster = { "tools" => tool_roster, "discovery_warning" => discovery_warning }
286
+ update_fan_out_state!(run) do |state|
287
+ state.merge(roster).merge(judge_usage ? { "judge_usage" => judge_usage } : {})
288
+ end
289
+
290
+ selection = @selection.to_h
291
+ scenarios.product(specs).each do |scenario, spec|
292
+ EvaluationReplayJob.perform_later(run.id, scenario.id, spec.to_h, selection)
293
+ end
294
+ run
295
+ end
296
+
297
+ # A fanned-out run keys its results by provider and model, where the
298
+ # sequential path keys them by label, so one model listed under two
299
+ # labels ("gpt-5-mini" and "openai/gpt-5-mini") would be replayed once
300
+ # and reported under one of them; the run refuses the selection instead.
301
+ def refuse_duplicate_specs!(specs)
302
+ duplicates = specs.group_by { |spec| [ spec.provider, spec.model ] }.select { |_pair, group| group.size > 1 }
303
+ return if duplicates.empty?
304
+
305
+ names = duplicates.map { |(provider, model), group| "#{provider}/#{model} as #{group.map(&:label).join(' and ')}" }
306
+ raise ArgumentError, "the same model is listed more than once (#{names.to_sentence}); list each provider/model once"
307
+ end
308
+
309
+ def model_spec_from(value)
310
+ value = value.to_h.stringify_keys
311
+ Evals::ModelSpec.new(label: value["label"].presence || value["model"], provider: value["provider"].to_s, model: value["model"])
312
+ end
313
+
314
+ # The run's candidate models, in the order it selected them, keyed by
315
+ # provider and model as a result row records them.
316
+ def fan_out_specs(run)
317
+ Array(run.selection&.dig("models")).filter_map do |entry|
318
+ model_spec_from(entry) if entry.is_a?(Hash) && entry["model"].present?
319
+ end.index_by { |spec| [ spec.provider, spec.model ] }
320
+ end
321
+
322
+ def stored_result(run, scenario, spec)
323
+ run.scenario_results.find_by(evaluation_scenario_id: scenario.id, model: spec.model, provider: spec.provider)
324
+ end
325
+
326
+ # How many results the run waits for: one per selected scenario still in
327
+ # the suite, per model.
328
+ def expected_results(run, specs)
329
+ ids = Array(run.selection&.dig("scenario_ids"))
330
+ @evaluation.scenarios.where(id: ids).count * specs.size
331
+ end
332
+
333
+ # Claims a pending result for this job before it is scored. Of two judge
334
+ # jobs delivered for one result (a replay job delivered again after it
335
+ # enqueued the first), only the one whose claim lands scores it, so the
336
+ # judge is asked once and the run's meter counts each call once. The
337
+ # claim is a conditional write on the row's timestamp: it holds no lock
338
+ # while the judge is asked, and a row whose claimant then stops is still
339
+ # pending for the next delivery.
340
+ def claim_result!(row)
341
+ EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at).update_all(updated_at: Time.current) == 1
342
+ end
343
+
344
+ # What Evals::Runner does with a replay that raises: an errored replay,
345
+ # scored like any other.
346
+ def fan_out_replay(task, spec)
347
+ replay(task, spec)
348
+ rescue StandardError => e
349
+ Evals::Replay.failed(e)
350
+ end
351
+
352
+ def store_replay(run, scenario, task, spec, replay)
353
+ run.scenario_results.create!(
354
+ scenario: scenario,
355
+ agent_run_id: replay.metadata["agent_run_id"],
356
+ model: spec.model,
357
+ provider: spec.provider,
358
+ status: :pending,
359
+ output: replay.answer.to_s.byteslice(0, 20_000).to_s.scrub.presence,
360
+ tool_calls: replay.tool_calls,
361
+ duration_ms: replay.duration_ms,
362
+ input_tokens: replay.input_tokens,
363
+ output_tokens: replay.output_tokens,
364
+ cost: replay.cost,
365
+ diagnosis: {
366
+ "_replay_metadata" => replay.metadata,
367
+ "_scenario_snapshot" => task.to_h.merge(expectations: task.expectations)
368
+ },
369
+ error_message: replay.error
370
+ )
371
+ end
372
+
373
+ # The stored replay, with the answer as the agent run recorded it. The
374
+ # result keeps the first 20,000 bytes of the answer to show; a rule or
375
+ # an expectation (contains, not_contains, min_length) reads all of it, as
376
+ # it does on the sequential path, so a long answer scores the same
377
+ # fanned out.
378
+ def replay_from(row)
379
+ Evals::Replay.new(
380
+ answer: row.agent_run&.output.presence || row.output, tool_calls: row.tool_calls, duration_ms: row.duration_ms,
381
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens, error: row.error_message,
382
+ cost: row.cost&.to_f, metadata: row.replay_metadata
383
+ )
384
+ end
385
+
386
+ # A Runner that scores one replay. Its refine_gate shares the run-wide
387
+ # judge_limit with every other result's scorer, and its roster is the one
388
+ # discovered when the run fanned out (a run fanned out before the roster
389
+ # was kept discovers its own).
390
+ def fan_out_scorer(run)
391
+ Evals::Runner.new(
392
+ scenarios: [],
393
+ models: [],
394
+ criteria: sample_criteria,
395
+ judge: evals_judge,
396
+ available_tools: fan_out_state(run).fetch("tools") { tool_roster },
397
+ instructions: @evaluation.agent.instructions,
398
+ agent_name: @evaluation.agent.name,
399
+ threshold: PASS_THRESHOLD,
400
+ replay: ->(*) { raise ArgumentError, "a fanned-out result is scored from its stored replay" },
401
+ refine_gate: -> { reserve_refinement!(run) }
402
+ )
403
+ end
404
+
405
+ def reserve_refinement!(run)
406
+ reserved = false
407
+ update_fan_out_state!(run) do |state|
408
+ count = state["refinements"].to_i
409
+ next state if count >= Evals::Runner::DEFAULT_JUDGE_LIMIT
410
+
411
+ reserved = true
412
+ state.merge("refinements" => count + 1)
413
+ end
414
+ reserved
415
+ end
416
+
417
+ # The run's fan-out bookkeeping, kept in scores["_fan_out"] until the run
418
+ # completes and its scores are written: the tool roster and discovery
419
+ # warning found before the replays, the judge usage spent before them
420
+ # (judge-defined KPIs), and the recommendations reserved so far.
421
+ def fan_out_state(run)
422
+ state = run.scores.is_a?(Hash) ? run.scores["_fan_out"] : nil
423
+ state.is_a?(Hash) ? state : {}
424
+ end
425
+
426
+ # Changes the bookkeeping under the run's lock. A completed run's scores
427
+ # are left as written: a reservation that lands after the last result
428
+ # has nothing to reserve.
429
+ def update_fan_out_state!(run)
430
+ run.with_lock do
431
+ next unless run.running?
432
+
433
+ scores = run.scores.is_a?(Hash) ? run.scores : {}
434
+ run.update!(scores: scores.merge("_fan_out" => yield(fan_out_state(run))))
435
+ end
436
+ end
437
+
438
+ # What completes the run: the attributes the sequential path writes at
439
+ # the end of #call, from the stored results.
440
+ def completion_attributes(run, rows, specs)
441
+ if rows.empty?
442
+ return { status: :failed, error_message: "Every selected scenario was removed during the run", completed_at: Time.current }
443
+ end
444
+
445
+ order = specs.keys
446
+ rows = rows.sort_by do |row|
447
+ [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, order.index([ row.provider.to_s, row.model ]) || order.size ]
448
+ end
449
+ results = rows.map do |row|
450
+ Evals::Result.new(
451
+ scenario: Evals::Scenario.from_hash(row.evaluated_scenario),
452
+ spec: specs.fetch([ row.provider.to_s, row.model ]) { model_spec_from(row.attributes.slice("provider", "model")) },
453
+ replay: replay_from(row),
454
+ scores: row.scores.to_h, score: row.score, status: row.status,
455
+ diagnosis: row.evaluation_diagnosis.presence
456
+ )
457
+ end
458
+ report = Evals::Report.new(results: results, models: specs.values, judge: evals_judge,
459
+ instructions: @evaluation.agent.instructions, threshold: PASS_THRESHOLD)
460
+ state = fan_out_state(run)
461
+
462
+ # The other jobs' judge calls go into this runner's meter before the
463
+ # verdict is asked for, so the run's usage, and anything scores_for
464
+ # reads from the meter, covers every call once.
465
+ @judge_usage = combined_judge_usage([ state["judge_usage"], *rows.map { |row| row.diagnosis.to_h["_judge_usage"] }, judge_usage ])
466
+
467
+ {
468
+ status: :complete,
469
+ scores: scores_for(report, run),
470
+ samples_evaluated: results.size,
471
+ samples_passed: results.count(&:passed?),
472
+ error_message: state.key?("discovery_warning") ? state["discovery_warning"] : discovery_warning,
473
+ completed_at: Time.current
474
+ }
475
+ end
476
+
477
+ # Sums the judge usage of the run's parts into the one meter
478
+ # scores["_judge_usage"] holds for a sequential run.
479
+ def combined_judge_usage(parts)
480
+ parts = parts.select { |part| part.is_a?(Hash) }
481
+ return nil if parts.empty?
482
+
483
+ costs = parts.filter_map { |part| part["cost"] }
484
+ {
485
+ "calls" => parts.sum { |part| part["calls"].to_i },
486
+ "input_tokens" => parts.sum { |part| part["input_tokens"].to_i },
487
+ "output_tokens" => parts.sum { |part| part["output_tokens"].to_i },
488
+ "cost" => costs.any? ? costs.sum.round(6) : nil,
489
+ "model" => parts.filter_map { |part| part["model"] }.first,
490
+ "by_kind" => tally(parts, "by_kind"),
491
+ "models" => tally(parts, "models")
492
+ }
493
+ end
494
+
495
+ def tally(parts, key)
496
+ parts.each_with_object(Hash.new(0)) do |part, counts|
497
+ part[key].to_h.each { |name, count| counts[name] += count.to_i }
498
+ end.to_h
499
+ end
500
+
501
+ def error_text(error)
502
+ error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
503
+ end
504
+
147
505
  def default_report(tasks, specs, on_result)
148
506
  Evals::Runner.new(
149
507
  scenarios: tasks,
@@ -407,16 +765,35 @@ module ActionAgent
407
765
  scores["_models"] = report.summary_by_model
408
766
  scores["_recommendations"] = report.recommendations
409
767
  scores["_verdict"] = report.verdict if report.comparing?
768
+ # The framework gained Report#comparison after the oldest activeagent
769
+ # this engine runs against.
770
+ comparison = report.comparison if report.respond_to?(:comparison)
771
+ scores["_comparison"] = comparison if comparison
410
772
  scores["_selection"] = run.selection
411
773
  scores["_metadata"] = report.metadata
412
774
  scores["_judge_label"] = report.judge_label || report.judge&.label
413
775
  # The judge's own spend, apart from the replays' (which the results
414
776
  # carry and EvaluationRun#usage sums). A host adapter that runs its own
415
777
  # judge is out of reach of this meter, so its runs record none.
416
- scores["_judge_usage"] = judge_usage if judge_usage
778
+ if judge_usage
779
+ scores["_judge_usage"] = judge_usage
780
+ name_judge!(scores, report.judge&.label)
781
+ end
417
782
  scores
418
783
  end
419
784
 
785
+ # Records who judged once the calls are in. A judge with no model pinned
786
+ # started out labelled by its provider; it is renamed after the model
787
+ # that answered, here and on the verdict it wrote.
788
+ def name_judge!(scores, provisional)
789
+ scores["_judge"] = judge_descriptor
790
+ label = judge_display_label
791
+ return unless label && provisional && scores["_judge_label"] == provisional
792
+
793
+ scores["_judge_label"] = label
794
+ scores["_verdict"]["judge"] = label if scores["_verdict"].is_a?(Hash) && scores["_verdict"]["judge"] == provisional
795
+ end
796
+
420
797
  # --- judge ------------------------------------------------------------
421
798
 
422
799
  def sample_criteria
@@ -481,7 +858,7 @@ module ActionAgent
481
858
  def evals_judge
482
859
  return nil unless judge_available?
483
860
 
484
- @evals_judge ||= Evals::Judge.new(label: @evaluation.judge_model.presence || judge_provider.to_s) do |instructions:, prompt:, kind:|
861
+ @evals_judge ||= Evals::Judge.new(label: judge_display_label) do |instructions:, prompt:, kind:|
485
862
  judge_generate(kind, message: prompt, instructions: instructions).message&.content
486
863
  end
487
864
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.9.1"
4
+ VERSION = "1.9.2"
5
5
  end
data/lib/action_agent.rb CHANGED
@@ -685,6 +685,20 @@ module ActionAgent
685
685
  # @return [Proc, nil]
686
686
  attr_accessor :scenario_evaluation_adapter_resolver
687
687
 
688
+ # Whether a scenario evaluation runs as background jobs, one per
689
+ # scenario x model (EvaluationReplayJob) followed by one to score it
690
+ # (EvaluationJudgeJob), instead of as one job that replays the suite in
691
+ # order. The jobs run on the :agents queue, so a run is as parallel as
692
+ # the host's workers for that queue: a single-worker box stays
693
+ # sequential. The last result to be scored completes the run.
694
+ #
695
+ # A run that drives a sandbox's browser, and a run a host adapter
696
+ # (scenario_evaluation_adapter_resolver) replays, stay sequential: the
697
+ # browser is one page, and the adapter owns its own scheduling.
698
+ # Off unless set.
699
+ # @return [Boolean]
700
+ attr_accessor :scenario_evaluation_fan_out
701
+
688
702
  # How many answerable candidates an exploration's review pre-selects for
689
703
  # acceptance: an Integer, or a Proc called with (owner) that returns one.
690
704
  # A platform can cap a free plan's suite this way. Unset, or nil from the
@@ -1243,6 +1257,7 @@ module ActionAgent
1243
1257
  @capture_dashboard_sessions = true
1244
1258
 
1245
1259
  @scenario_evaluation_adapter_resolver = nil
1260
+ @scenario_evaluation_fan_out = false
1246
1261
  @table_name_prefix = "active_agent_"
1247
1262
  @agent_polymorphic_name = nil
1248
1263
  @encrypt_credentials = true
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activeagent
@@ -190,6 +190,8 @@ files:
190
190
  - app/jobs/action_agent/application_job.rb
191
191
  - app/jobs/action_agent/code_session_job.rb
192
192
  - app/jobs/action_agent/draft_pull_request_job.rb
193
+ - app/jobs/action_agent/evaluation_judge_job.rb
194
+ - app/jobs/action_agent/evaluation_replay_job.rb
193
195
  - app/jobs/action_agent/evaluation_run_job.rb
194
196
  - app/jobs/action_agent/exploration_job.rb
195
197
  - app/jobs/action_agent/input_request_expiry_job.rb