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
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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) {
|
|
674
|
+
define_singleton_method(:name) { JUDGE_AGENT_NAME }
|
|
619
675
|
generate_with provider, **options
|
|
620
676
|
end
|
|
621
677
|
end
|