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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +60 -60
- data/app/controllers/action_agent/api/evaluations_controller.rb +15 -3
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +2 -2
- data/app/jobs/action_agent/evaluation_judge_job.rb +39 -0
- data/app/jobs/action_agent/evaluation_replay_job.rb +28 -0
- data/app/models/action_agent/evaluation_run.rb +20 -3
- data/app/services/action_agent/evaluation_fix_comparison.rb +4 -1
- data/app/services/action_agent/evaluation_report_import.rb +7 -2
- data/app/services/action_agent/evaluation_runner_service.rb +59 -3
- data/app/services/action_agent/scenario_evaluation_runner.rb +380 -3
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +15 -0
- metadata +4 -2
|
@@ -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
|
-
|
|
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:
|
|
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
|
data/lib/action_agent/version.rb
CHANGED
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.
|
|
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-
|
|
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
|