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.
@@ -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
@@ -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 })
@@ -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
@@ -78,6 +78,15 @@ module ActionAgent
78
78
  verdict.to_h.stringify_keys.presence if verdict.is_a?(Hash)
79
79
  end
80
80
 
81
+ # The model comparison a comparison run recorded (Report#comparison):
82
+ # each model's pass rate with its interval, every pair's difference and
83
+ # whether it is decisive, and the conclusion. nil for a single-model
84
+ # run, or one recorded before runs kept it.
85
+ def recorded_comparison
86
+ comparison = scores&.dig("_comparison")
87
+ comparison if comparison.is_a?(Hash) && comparison.present?
88
+ end
89
+
81
90
  # How the report names the judge, the way the suite panel does: the
82
91
  # judge the recorded verdict names — unless that is only the pass-rate
83
92
  # ranking — else the evaluation's judge model. nil when neither is set,
@@ -220,7 +229,7 @@ module ActionAgent
220
229
  # writes with Report#to_html. The run's recorded verdict and judge go
221
230
  # with it: the report is not to re-rank the rebuilt results by pass
222
231
  # rate and show a different judge's pick, verdict or `judged by` than
223
- # the suite panel does. Raises ActiveRecord::RecordNotFound via the
232
+ # the suite panel does, nor resample the comparison the run recorded. Raises ActiveRecord::RecordNotFound via the
224
233
  # caller for a run of a generation-sampling evaluation, which has no
225
234
  # scenario results to report on.
226
235
  #
@@ -234,7 +243,10 @@ module ActionAgent
234
243
  # The engine runs against every activeagent since 1.4, so the report
235
244
  # kwargs that arrived later are passed only when Report.new takes them.
236
245
  def to_report(links: report_links, estimate: true)
237
- rows = scenario_results.includes(:scenario).sort_by do |row|
246
+ # A replay not yet scored (a fanned-out run in progress) is not a
247
+ # result the report can grade, so it is left out rather than read as
248
+ # a failure.
249
+ rows = scenario_results.where.not(status: :pending).includes(:scenario).sort_by do |row|
238
250
  [ row.evaluated_scenario["position"].to_i, row.evaluation_scenario_id, row.model ]
239
251
  end
240
252
  breakdown = cost_breakdown if estimate
@@ -276,6 +288,7 @@ module ActionAgent
276
288
  }
277
289
  kwargs[:judge_usage] = breakdown.judge_usage_run if breakdown && self.class.report_accepts?(:judge_usage)
278
290
  kwargs[:release] = release_summary if release_summary && self.class.report_accepts?(:release)
291
+ kwargs[:comparison] = recorded_comparison if recorded_comparison && self.class.report_accepts?(:comparison)
279
292
  self.class.report_class.new(**kwargs)
280
293
  end
281
294
 
@@ -319,7 +332,11 @@ module ActionAgent
319
332
  # for it, and — when estimating — how its cost was priced.
320
333
  def replay_metadata_for(row, breakdown)
321
334
  metadata = row.replay_metadata.dup
322
- reported_judge = row.diagnosis.is_a?(Hash) ? row.diagnosis["_judge_usage"] : nil
335
+ # A fanned-out run's results each keep what their scoring job spent on
336
+ # the judge, already folded into the run's meter. The meter is the
337
+ # run's figure (EvaluationRunCost#result_judge reads it the same way),
338
+ # so the parts are not counted a second time.
339
+ reported_judge = row.diagnosis.is_a?(Hash) && !judge_usage_meter ? row.diagnosis["_judge_usage"] : nil
323
340
  metadata["judge_usage"] = reported_judge if reported_judge.is_a?(Hash)
324
341
  return metadata unless breakdown
325
342
 
@@ -6,8 +6,11 @@ module ActionAgent
6
6
  @session = session
7
7
  end
8
8
 
9
+ # +after+ is pending while the verification run is in progress and, with
10
+ # a fanned-out run, while its stored replay waits to be scored: not a
11
+ # result yet, so not a regression.
9
12
  def self.change(before, after)
10
- return "pending" unless after
13
+ return "pending" if after.nil? || after.pending?
11
14
  return after.passed? ? "added_pass" : "added_fail" unless before
12
15
  return after.passed? ? "unchanged_pass" : "regressed" if before.passed?
13
16
  after.passed? ? "fixed" : "still_failing"
@@ -568,12 +568,17 @@ module ActionAgent
568
568
  # results as the application reported them — no cost is estimated here,
569
569
  # so the summaries stored stay the application's own figures; the API
570
570
  # adds the estimates when it serves the run (EvaluationRun#model_summaries).
571
+ #
572
+ # The comparison is worked out here from the stored results too, rather
573
+ # than taken from the payload, like the summaries.
571
574
  def summarized_scores(run)
572
575
  rebuilt = run.to_report(estimate: false)
576
+ comparison = rebuilt.comparison if rebuilt.respond_to?(:comparison)
573
577
  rebuilt.criterion_scores.merge(
574
578
  "_models" => rebuilt.summary_by_model,
575
- "_recommendations" => rebuilt.recommendations
576
- ).merge(recorded_scores)
579
+ "_recommendations" => rebuilt.recommendations,
580
+ "_comparison" => comparison
581
+ ).compact.merge(recorded_scores)
577
582
  end
578
583
 
579
584
  # --- validation ------------------------------------------------------------
@@ -10,6 +10,8 @@ module ActionAgent
10
10
  # credentials and is skipped — never faked — when none are available.
11
11
  class EvaluationRunnerService
12
12
  PASS_THRESHOLD = 0.7
13
+ # The class name the judge runs as, which its traces and the run record.
14
+ JUDGE_AGENT_NAME = "EvaluationJudgeAgent"
13
15
 
14
16
  def self.call(evaluation)
15
17
  new(evaluation).call
@@ -66,6 +68,7 @@ module ActionAgent
66
68
 
67
69
  scores["_cohorts"] = cohort_summaries(samples, per_sample_scores) if samples.any?
68
70
  scores["_judge_usage"] = judge_usage if judge_usage
71
+ scores["_judge"] = judge_descriptor if judge_usage
69
72
 
70
73
  run.update!(
71
74
  status: :complete,
@@ -136,6 +139,7 @@ module ActionAgent
136
139
  [ model, cohort_summary(samples, per_model_sample_scores[model] || {}) ]
137
140
  end
138
141
  scores["_judge_usage"] = judge_usage if judge_usage
142
+ scores["_judge"] = judge_descriptor if judge_usage
139
143
 
140
144
  run.update!(
141
145
  status: :complete,
@@ -236,7 +240,8 @@ module ActionAgent
236
240
  # first. Persisted as scores["_judge_usage"]:
237
241
  #
238
242
  # { "calls", "input_tokens", "output_tokens", "cost", "model",
239
- # "by_kind" => { "score" => n, "recommend" => n, "verdict" => n, "define" => n } }
243
+ # "by_kind" => { "score" => n, "recommend" => n, "verdict" => n, "define" => n },
244
+ # "models" => { "<model that answered>" => n } }
240
245
  #
241
246
  # nil until the judge has been asked something, so a rules-only run
242
247
  # records no judge at all rather than a judge that cost nothing.
@@ -263,13 +268,42 @@ module ActionAgent
263
268
  model = (response.respond_to?(:model) && response.model.presence) || @evaluation.judge_model.presence
264
269
  cost = ModelPricing.estimate(model: model, provider: judge_provider, input_tokens: input_tokens, output_tokens: output_tokens)
265
270
 
266
- @judge_usage ||= { "calls" => 0, "input_tokens" => 0, "output_tokens" => 0, "cost" => nil, "model" => nil, "by_kind" => {} }
271
+ @judge_usage ||= { "calls" => 0, "input_tokens" => 0, "output_tokens" => 0, "cost" => nil, "model" => nil, "by_kind" => {}, "models" => {} }
267
272
  @judge_usage["calls"] += 1
268
273
  @judge_usage["input_tokens"] += input_tokens
269
274
  @judge_usage["output_tokens"] += output_tokens
270
275
  @judge_usage["cost"] = (@judge_usage["cost"] || 0.0) + cost if cost
271
276
  @judge_usage["model"] ||= model
272
277
  @judge_usage["by_kind"][kind.to_s] = @judge_usage["by_kind"].fetch(kind.to_s, 0) + 1
278
+ (@judge_usage["models"] ||= {})[model] = @judge_usage["models"].fetch(model, 0) + 1 if model
279
+ end
280
+
281
+ # The judge as a run records it (scores["_judge"]): the agent class it
282
+ # runs as, its provider, the model it asks for and where that setting
283
+ # came from, and the models that answered. A judge with no model pinned
284
+ # runs on its provider's default, and OpenRouter's (openrouter/auto) is a
285
+ # router that can answer each call with a different model, so "answered"
286
+ # is what actually judged. nil when no provider has credentials.
287
+ def judge_descriptor
288
+ return nil unless judge_provider
289
+
290
+ {
291
+ "agent" => JUDGE_AGENT_NAME,
292
+ "provider" => judge_provider.to_s,
293
+ "model" => judge_requested_model,
294
+ "model_source" => judge_model_source,
295
+ "answered" => judge_usage&.dig("models").to_h
296
+ }
297
+ end
298
+
299
+ # How a run names its judge: the model it asked for, else the one model
300
+ # that answered, else the provider's default.
301
+ def judge_display_label
302
+ return nil unless judge_provider
303
+ return judge_requested_model if judge_requested_model
304
+
305
+ answered = judge_usage&.dig("models").to_h.keys
306
+ answered.one? ? answered.first : "#{judge_provider} default"
273
307
  end
274
308
 
275
309
  # Every token the judge was billed for reading. Anthropic reports the
@@ -602,6 +636,28 @@ module ActionAgent
602
636
  @owner_provider_options[name.to_s] ||= ProviderCredentials.resolve(owner: owner, actor: nil, provider: name).options
603
637
  end
604
638
 
639
+ # The model the judge asks for, in the order judge_class applies them:
640
+ # the evaluation's judge_model, the owner's provider key, the host's
641
+ # config/active_agent.yml. nil leaves the choice to the provider.
642
+ def judge_requested_model
643
+ judge_model_setting&.last
644
+ end
645
+
646
+ def judge_model_source
647
+ judge_model_setting&.first || "provider_default"
648
+ end
649
+
650
+ def judge_model_setting
651
+ return @judge_model_setting if defined?(@judge_model_setting)
652
+
653
+ config = ActiveAgent.configuration[judge_provider]
654
+ @judge_model_setting = {
655
+ "evaluation" => @evaluation.judge_model,
656
+ "provider_key" => owner_provider_options(judge_provider)[:model],
657
+ "config" => (config[:model] if config.respond_to?(:[]))
658
+ }.find { |_source, model| model.present? }&.then { |source, model| [ source, model.to_s ] }
659
+ end
660
+
605
661
  def owner
606
662
  @owner ||= @evaluation&.agent&.owner
607
663
  end
@@ -615,7 +671,7 @@ module ActionAgent
615
671
  options.merge!(owner_provider_options(provider))
616
672
 
617
673
  Class.new(ActiveAgent::Base) do
618
- define_singleton_method(:name) { "EvaluationJudgeAgent" }
674
+ define_singleton_method(:name) { JUDGE_AGENT_NAME }
619
675
  generate_with provider, **options
620
676
  end
621
677
  end