actionagent 1.9.2 → 1.9.3
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/README.md +23 -0
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -61
- data/app/controllers/action_agent/api/agents_controller.rb +23 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +49 -10
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +66 -2
- data/app/controllers/action_agent/api/evaluations_controller.rb +41 -3
- data/app/controllers/action_agent/api/interactions_controller.rb +55 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +13 -2
- data/app/controllers/action_agent/api/trace_reports_controller.rb +11 -6
- data/app/jobs/action_agent/code_session_job.rb +19 -2
- data/app/models/action_agent/agent.rb +54 -1
- data/app/models/action_agent/code_session.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +17 -2
- data/app/models/action_agent/provider_key.rb +5 -1
- data/app/queries/action_agent/trace_window_report.rb +60 -0
- data/app/serializers/action_agent/code_conversation_serializer.rb +86 -0
- data/app/serializers/action_agent/evaluation_serializer.rb +13 -2
- data/app/services/action_agent/agent_execution_service.rb +37 -0
- data/app/services/action_agent/agent_tool_roster.rb +53 -18
- data/app/services/action_agent/agent_toolbox.rb +156 -3
- data/app/services/action_agent/browser_client_token.rb +86 -0
- data/app/services/action_agent/checkout_tools.rb +154 -0
- data/app/services/action_agent/code_conversation.rb +133 -0
- data/app/services/action_agent/codex_auth.rb +81 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +19 -1
- data/app/services/action_agent/diff_stat.rb +27 -0
- data/app/services/action_agent/evaluation_report_export.rb +175 -0
- data/app/services/action_agent/evaluation_report_import.rb +40 -17
- data/app/services/action_agent/evaluation_runner_service.rb +48 -12
- data/app/services/action_agent/local_sandbox_backend.rb +295 -30
- data/app/services/action_agent/mcp_catalog.rb +25 -0
- data/app/services/action_agent/mcp_client.rb +1 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -1
- data/app/services/action_agent/project_boot_fix.rb +10 -3
- data/app/services/action_agent/sandbox_orchestrator.rb +41 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +1 -0
- data/app/services/action_agent/slack_tools.rb +101 -0
- data/config/routes.rb +10 -1
- data/lib/action_agent/engine.rb +6 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +35 -2
- data/lib/generators/action_agent/templates/migrations/028_add_conversations_to_code_sessions.rb.erb +18 -0
- data/lib/tasks/evaluations.rake +78 -0
- metadata +13 -2
|
@@ -206,11 +206,13 @@ module ActionAgent
|
|
|
206
206
|
# listing reports it: an API key the owner connected (auth "api_key"),
|
|
207
207
|
# this machine's own login (auth "local_login", with its status), or a
|
|
208
208
|
# login inside each checkout sandbox (auth "sandbox_login", with the
|
|
209
|
-
# actor's per-sandbox state under sandboxes).
|
|
209
|
+
# actor's per-sandbox state under sandboxes). Codex by CodexAuth's: an
|
|
210
|
+
# API key, or this machine's own login.
|
|
210
211
|
def connections
|
|
211
212
|
claude_code = ClaudeCodeAuth.status(ProviderKey.for_owner(@owner),
|
|
212
213
|
sandboxes: SandboxSession.for_owner(@owner).where(sandbox_type: "app_runtime").recent.limit(20),
|
|
213
214
|
user_id: @actor.respond_to?(:id) ? @actor.id : nil)
|
|
215
|
+
codex = CodexAuth.status(ProviderKey.for_owner(@owner))
|
|
214
216
|
{
|
|
215
217
|
github: { supported: true, connected: GithubConnection.for_owner(@owner).exists? },
|
|
216
218
|
claude_code: {
|
|
@@ -220,6 +222,12 @@ module ActionAgent
|
|
|
220
222
|
login: claude_code[:login],
|
|
221
223
|
sandboxes: claude_code[:sandboxes]
|
|
222
224
|
}.compact,
|
|
225
|
+
codex: {
|
|
226
|
+
supported: codex_sessions_supported?,
|
|
227
|
+
connected: codex[:connected],
|
|
228
|
+
auth: codex[:mode],
|
|
229
|
+
login: codex[:login]
|
|
230
|
+
}.compact,
|
|
223
231
|
coi: { supported: false }
|
|
224
232
|
}
|
|
225
233
|
end
|
|
@@ -241,6 +249,16 @@ module ActionAgent
|
|
|
241
249
|
false
|
|
242
250
|
end
|
|
243
251
|
|
|
252
|
+
# Codex needs a backend that advertises the runner (code_runners), and
|
|
253
|
+
# one its authentication works on (CodexAuth.backend_refusal).
|
|
254
|
+
def codex_sessions_supported?
|
|
255
|
+
orchestrator = SandboxOrchestrator.new
|
|
256
|
+
orchestrator.supports_code_runner?("codex") && CodexAuth.backend_refusal(orchestrator).nil?
|
|
257
|
+
rescue StandardError, LoadError => e
|
|
258
|
+
Rails.logger.warn("[ActionAgent] sandbox backend unavailable: #{e.message}")
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
244
262
|
def require_processing_consent!
|
|
245
263
|
return if @allow_provider_processing == true
|
|
246
264
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Counts what a unified diff changes: { files:, additions:, deletions: }.
|
|
5
|
+
# A code session's diff runs from the checkout's base commit, so each turn
|
|
6
|
+
# of a conversation reports everything changed since the checkout, up to
|
|
7
|
+
# and including that turn.
|
|
8
|
+
module DiffStat
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param diff [String, nil]
|
|
12
|
+
# @return [Hash, nil] nil when there is no diff
|
|
13
|
+
def call(diff)
|
|
14
|
+
return nil if diff.blank?
|
|
15
|
+
|
|
16
|
+
files = additions = deletions = 0
|
|
17
|
+
diff.each_line do |line|
|
|
18
|
+
if line.start_with?("diff --git ") then files += 1
|
|
19
|
+
elsif line.start_with?("+++ ", "--- ") then next
|
|
20
|
+
elsif line.start_with?("+") then additions += 1
|
|
21
|
+
elsif line.start_with?("-") then deletions += 1
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
{ files: files, additions: additions, deletions: deletions }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ActionAgent
|
|
7
|
+
# A complete scenario run as the version-1 envelope
|
|
8
|
+
# ActiveAgent::Evals::Publisher sends, built with Publisher.envelope so the
|
|
9
|
+
# two share one shape. What the export endpoints and
|
|
10
|
+
# `action_agent:evaluations:export` serve: POSTed to another install's
|
|
11
|
+
# <mount>/api/evaluation_reports it is stored there, and POSTed back to this
|
|
12
|
+
# one it is an identical retry (EvaluationReportImport accepts a run's own
|
|
13
|
+
# export as identical to the run).
|
|
14
|
+
#
|
|
15
|
+
# The identities come from the run:
|
|
16
|
+
# run_id — an imported run's own run_id, exactly as published. A run
|
|
17
|
+
# the dashboard executed has none, so it is given
|
|
18
|
+
# "dashboard-run-<id>-<created_at as YYYYMMDDhhmmss UTC>":
|
|
19
|
+
# stable for the run, and unlikely to meet another
|
|
20
|
+
# install's run of the same id where both are imported.
|
|
21
|
+
# source — an imported run's source; for a dashboard run the agent's
|
|
22
|
+
# service, else the host application's name ("support-hub").
|
|
23
|
+
# agent_name — the agent's class name, else its name.
|
|
24
|
+
# suite — an imported run's suite; for a dashboard run the
|
|
25
|
+
# evaluation's name.
|
|
26
|
+
#
|
|
27
|
+
# The report is EvaluationRun#to_report(estimate: false): what the run
|
|
28
|
+
# recorded, with no cost estimated, so the same run always exports the
|
|
29
|
+
# same envelope. Its metadata leaves out the run's database id.
|
|
30
|
+
class EvaluationReportExport
|
|
31
|
+
# The run cannot be exported: it is not complete, or its evaluation is
|
|
32
|
+
# not a scenario suite.
|
|
33
|
+
class NotExportable < StandardError; end
|
|
34
|
+
# #markdown was asked for a part the report does not have.
|
|
35
|
+
class UnknownPart < ArgumentError; end
|
|
36
|
+
|
|
37
|
+
DASHBOARD_RUN_ID = /\Adashboard-run-(\d+)-(\d{14})\z/
|
|
38
|
+
STAMP_FORMAT = "%Y%m%d%H%M%S"
|
|
39
|
+
|
|
40
|
+
# The run_id a run exports under.
|
|
41
|
+
def self.run_id_for(run)
|
|
42
|
+
run.external_run_id.presence || "dashboard-run-#{run.id}-#{run.created_at.utc.strftime(STAMP_FORMAT)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The run +run_id+ names for the tenant +account+ (nil on a single-tenant
|
|
46
|
+
# install), or nil. An imported run is found by the run_id it was
|
|
47
|
+
# published under, within the tenant that published it, as
|
|
48
|
+
# EvaluationReportImport finds it. A dashboard run is found by its
|
|
49
|
+
# exported run_id, when its agent is one of the tenant's
|
|
50
|
+
# (ActionAgent.agents_for); a single-tenant key reaches every agent, as
|
|
51
|
+
# the import's does.
|
|
52
|
+
def self.find(run_id, account: nil)
|
|
53
|
+
return nil unless run_id.is_a?(String) && run_id.present?
|
|
54
|
+
|
|
55
|
+
imported = EvaluationRun.find_by(external_tenant: account ? account.id.to_s : "", external_run_id: run_id)
|
|
56
|
+
return imported if imported
|
|
57
|
+
|
|
58
|
+
match = DASHBOARD_RUN_ID.match(run_id)
|
|
59
|
+
return nil unless match
|
|
60
|
+
|
|
61
|
+
run = EvaluationRun.where(external_run_id: nil).find_by(id: match[1].to_i)
|
|
62
|
+
return nil unless run && run_id_for(run) == run_id
|
|
63
|
+
return run if account.nil? && !ActionAgent.multi_tenant?
|
|
64
|
+
|
|
65
|
+
run if account && ActionAgent.agents_for(account).exists?(id: run.evaluation.agent_id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
attr_reader :run
|
|
69
|
+
|
|
70
|
+
def initialize(run)
|
|
71
|
+
@run = run
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_id
|
|
75
|
+
self.class.run_id_for(run)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Raises NotExportable for a run that is not a complete scenario run:
|
|
79
|
+
# what every format of the export, not only the envelope, needs.
|
|
80
|
+
def exportable!
|
|
81
|
+
raise NotExportable, "Only a complete run can be exported" unless run.complete?
|
|
82
|
+
raise NotExportable, "Only a scenario suite's run can be exported" unless evaluation.scenario_suite?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Raises NotExportable for a run that is not a complete scenario run.
|
|
86
|
+
def envelope
|
|
87
|
+
@envelope ||= begin
|
|
88
|
+
exportable!
|
|
89
|
+
|
|
90
|
+
built = ActiveAgent::Evals::Publisher.envelope(
|
|
91
|
+
report: run.to_report(links: {}, estimate: false),
|
|
92
|
+
run_id: run_id, source: source, agent_name: agent_name, suite: suite
|
|
93
|
+
)
|
|
94
|
+
# As JSON reads it back, so the digest is the one a POST of it gets.
|
|
95
|
+
JSON.parse(JSON.generate(built)).tap do |parsed|
|
|
96
|
+
# The run's database id means nothing on another install; run_id
|
|
97
|
+
# is the run's identity there.
|
|
98
|
+
metadata = parsed["report"]["metadata"]
|
|
99
|
+
metadata.delete("run") if metadata.is_a?(Hash) && metadata["run"] == run.id
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_json(*)
|
|
105
|
+
JSON.pretty_generate(envelope)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Parts of the report #markdown serves on its own.
|
|
109
|
+
MARKDOWN_PARTS = %w[report verdict fix_items scenario].freeze
|
|
110
|
+
|
|
111
|
+
# The run's report as GitHub-flavored Markdown
|
|
112
|
+
# (ActiveAgent::Evals::ReportMarkdown), with the costs the dashboard shows:
|
|
113
|
+
# the whole report, or one +part+ — "verdict" (with the model
|
|
114
|
+
# comparison), "fix_items" (for +model+, a model label, when given) or
|
|
115
|
+
# "scenario" (the one +scenario+ names). Raises UnknownPart for an
|
|
116
|
+
# unknown part and ActiveRecord::RecordNotFound for a scenario the run
|
|
117
|
+
# has no result for.
|
|
118
|
+
def markdown(part: nil, model: nil, scenario: nil)
|
|
119
|
+
report = run.to_report(links: {})
|
|
120
|
+
case part.presence || "report"
|
|
121
|
+
when "report" then report.to_markdown
|
|
122
|
+
when "verdict" then report.verdict_markdown
|
|
123
|
+
when "fix_items" then report.fix_items_markdown(model: model.presence)
|
|
124
|
+
when "scenario" then scenario_markdown(report, scenario.to_s)
|
|
125
|
+
else raise UnknownPart, "part is one of #{MARKDOWN_PARTS.join(', ')}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The digest EvaluationReportImport records for this envelope.
|
|
130
|
+
def digest
|
|
131
|
+
EvaluationReportImport.digest(envelope)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# "orders-eu-run-3.json" for the third run of "orders (eu)"; a part of
|
|
135
|
+
# the Markdown report adds its name: "orders-eu-run-3-fix-items.md".
|
|
136
|
+
def filename(extension, number: EvaluationSerializer.run_number(evaluation, run), part: nil)
|
|
137
|
+
base = "#{evaluation.name.to_s.parameterize.presence || "evaluation-#{evaluation.id}"}-run-#{number}"
|
|
138
|
+
base += "-#{part.to_s.dasherize.parameterize}" if part.present? && part.to_s != "report"
|
|
139
|
+
"#{base}.#{extension}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def scenario_markdown(report, key)
|
|
145
|
+
raise ActiveRecord::RecordNotFound, "The run has no result for scenario #{key}" unless report.results.any? { |result| result.scenario.key == key }
|
|
146
|
+
|
|
147
|
+
report.scenario_markdown(key)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def evaluation
|
|
151
|
+
run.evaluation
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def agent
|
|
155
|
+
evaluation.agent
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def external
|
|
159
|
+
value = evaluation.config.to_h["external"]
|
|
160
|
+
run.imported? && value.is_a?(Hash) ? value : {}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def source
|
|
164
|
+
(external["source"].presence || agent.service_name.presence || Rails.application.class.module_parent_name.underscore.dasherize).first(200)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def agent_name
|
|
168
|
+
(agent.agent_class_name.presence || agent.name).to_s.first(100)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def suite
|
|
172
|
+
(external["suite"].presence || evaluation.name).to_s.first(200)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -30,11 +30,14 @@ module ActionAgent
|
|
|
30
30
|
# leaves the run unrecorded rather than claiming the dashboard's latest.
|
|
31
31
|
#
|
|
32
32
|
# An identical retry returns the stored run, before anything is asked of the
|
|
33
|
-
# `admit` callable.
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
33
|
+
# `admit` callable. So does a run's own export (EvaluationReportExport)
|
|
34
|
+
# posted back to the install and tenant that hold the run, whether the run
|
|
35
|
+
# was imported or executed by the dashboard. Different content under a
|
|
36
|
+
# run_id already stored raises Conflict. Imports for one owner run one at a
|
|
37
|
+
# time under an advisory lock (OwnerLock), so concurrent first deliveries
|
|
38
|
+
# find or create one agent, evaluation and scenario set, and the unique
|
|
39
|
+
# index on the run's tenant and run_id settles any delivery the lock does
|
|
40
|
+
# not serialize.
|
|
38
41
|
class EvaluationReportImport
|
|
39
42
|
class Error < StandardError; end
|
|
40
43
|
# The payload is not a report the engine can store as sent; 422.
|
|
@@ -119,6 +122,21 @@ module ActionAgent
|
|
|
119
122
|
new(payload, account, admit).call
|
|
120
123
|
end
|
|
121
124
|
|
|
125
|
+
# The digest a run records for the payload it was stored from: the
|
|
126
|
+
# SHA-256 of the payload's JSON with every object's keys sorted.
|
|
127
|
+
def self.digest(payload)
|
|
128
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(payload)))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.canonical(value)
|
|
132
|
+
case value
|
|
133
|
+
when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
|
|
134
|
+
when Array then value.map { |child| canonical(child) }
|
|
135
|
+
else value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_class_method :canonical
|
|
139
|
+
|
|
122
140
|
# Why this install cannot store reports, or nil when it can: an install
|
|
123
141
|
# with no evaluation tables (generated with --traces-only), or one whose
|
|
124
142
|
# evaluation runs lack a column a later migration adds.
|
|
@@ -142,7 +160,7 @@ module ActionAgent
|
|
|
142
160
|
|
|
143
161
|
def call
|
|
144
162
|
validate!
|
|
145
|
-
digest =
|
|
163
|
+
digest = self.class.digest(@payload)
|
|
146
164
|
existing = stored_run
|
|
147
165
|
return [ identical!(existing, digest), true ] if existing
|
|
148
166
|
|
|
@@ -240,14 +258,27 @@ module ActionAgent
|
|
|
240
258
|
@account ? @account.id.to_s : ""
|
|
241
259
|
end
|
|
242
260
|
|
|
261
|
+
# The run this run_id already names for the tenant: one stored from an
|
|
262
|
+
# earlier delivery, or one this install exported (a dashboard run's
|
|
263
|
+
# exported run_id), found the way the export endpoint finds it.
|
|
243
264
|
def stored_run
|
|
244
|
-
|
|
265
|
+
EvaluationReportExport.find(@payload["run_id"], account: @account)
|
|
245
266
|
end
|
|
246
267
|
|
|
268
|
+
# The stored run, when the payload is the report it was stored from or
|
|
269
|
+
# the run's own export (EvaluationReportExport), which is how an
|
|
270
|
+
# exported run posted back to its install is told from a different
|
|
271
|
+
# report under the same run_id.
|
|
247
272
|
def identical!(run, digest)
|
|
248
|
-
|
|
273
|
+
return run if run.external_report_digest == digest || exported_digest(run) == digest
|
|
249
274
|
|
|
250
|
-
|
|
275
|
+
raise Conflict, "run_id already exists with a different report"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def exported_digest(run)
|
|
279
|
+
EvaluationReportExport.new(run).digest
|
|
280
|
+
rescue EvaluationReportExport::NotExportable
|
|
281
|
+
nil
|
|
251
282
|
end
|
|
252
283
|
|
|
253
284
|
def admit!
|
|
@@ -815,13 +846,5 @@ module ActionAgent
|
|
|
815
846
|
else value
|
|
816
847
|
end
|
|
817
848
|
end
|
|
818
|
-
|
|
819
|
-
def canonical(value)
|
|
820
|
-
case value
|
|
821
|
-
when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
|
|
822
|
-
when Array then value.map { |child| canonical(child) }
|
|
823
|
-
else value
|
|
824
|
-
end
|
|
825
|
-
end
|
|
826
849
|
end
|
|
827
850
|
end
|
|
@@ -33,6 +33,10 @@ module ActionAgent
|
|
|
33
33
|
def call
|
|
34
34
|
run = @evaluation.evaluation_runs.create!(status: :running)
|
|
35
35
|
|
|
36
|
+
# A pin whose provider has no credentials fails here, before any work.
|
|
37
|
+
# A rules-only run never asks the judge, so a stale pin does not stop it.
|
|
38
|
+
judge_provider if @evaluation.judge_model.present? && judge_needed?
|
|
39
|
+
|
|
36
40
|
# judge_defined evaluations author their KPI criteria on first run.
|
|
37
41
|
ensure_judge_defined_kpis! if @evaluation.judge_defined?
|
|
38
42
|
|
|
@@ -83,19 +87,52 @@ module ActionAgent
|
|
|
83
87
|
raise
|
|
84
88
|
end
|
|
85
89
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# when the owner configured a host; nil when none. The judge runs
|
|
89
|
-
# `judge_model` as that provider's own model id.
|
|
90
|
+
# Unpinned judges keep the configured default. A pinned model uses the
|
|
91
|
+
# existing ModelSpec syntax, so adding an unrelated key cannot reroute it.
|
|
90
92
|
def judge_provider
|
|
91
|
-
@judge_provider ||=
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
@judge_provider ||= begin
|
|
94
|
+
raw = @evaluation&.judge_model.presence
|
|
95
|
+
spec = raw && ActiveAgent::Evals::ModelSpec.parse(raw, default_provider: nil)
|
|
96
|
+
if spec&.provider.present?
|
|
97
|
+
provider = spec.provider.to_sym
|
|
98
|
+
if judge_provider_configured?(provider)
|
|
99
|
+
provider
|
|
100
|
+
elsif default_judge_provider
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"No credentials for judge provider #{provider} (#{raw}); configure that provider or pin a provider-qualified gateway model ID"
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
default_judge_provider
|
|
106
|
+
end
|
|
107
|
+
end
|
|
95
108
|
end
|
|
96
109
|
|
|
97
110
|
private
|
|
98
111
|
|
|
112
|
+
# Whether a sample-based run asks the judge: to author KPIs, to score an
|
|
113
|
+
# llm_judge criterion, or for a comparison's verdict.
|
|
114
|
+
def judge_needed?
|
|
115
|
+
@evaluation.judge_defined? || @evaluation.llm_criteria.any? || @evaluation.compare_models.any?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def default_judge_provider
|
|
119
|
+
%i[anthropic openai openrouter ollama].find { |name| judge_provider_configured?(name) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def judge_provider_configured?(name)
|
|
123
|
+
owner_provider_options(name).any? || (name != :ollama && global_provider_token?(name))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A provider qualifier selects the route, not a prefix to send to the
|
|
127
|
+
# provider. Vendor prefixes inside an explicit gateway ID are retained.
|
|
128
|
+
def judge_pinned_model(provider = judge_provider)
|
|
129
|
+
raw = @evaluation&.judge_model.presence
|
|
130
|
+
return unless raw
|
|
131
|
+
|
|
132
|
+
spec = ActiveAgent::Evals::ModelSpec.parse(raw, default_provider: provider)
|
|
133
|
+
spec.provider.to_sym == provider ? spec.model : raw
|
|
134
|
+
end
|
|
135
|
+
|
|
99
136
|
# Scores each sample-based criterion once per candidate model cohort and
|
|
100
137
|
# asks the judge for a comparative verdict — "haiku vs qwen, judged".
|
|
101
138
|
def score_comparison(run, sample_criteria, telemetry_criteria)
|
|
@@ -652,7 +689,7 @@ module ActionAgent
|
|
|
652
689
|
|
|
653
690
|
config = ActiveAgent.configuration[judge_provider]
|
|
654
691
|
@judge_model_setting = {
|
|
655
|
-
"evaluation" =>
|
|
692
|
+
"evaluation" => judge_pinned_model,
|
|
656
693
|
"provider_key" => owner_provider_options(judge_provider)[:model],
|
|
657
694
|
"config" => (config[:model] if config.respond_to?(:[]))
|
|
658
695
|
}.find { |_source, model| model.present? }&.then { |source, model| [ source, model.to_s ] }
|
|
@@ -665,10 +702,9 @@ module ActionAgent
|
|
|
665
702
|
def judge_class
|
|
666
703
|
@judge_class ||= begin
|
|
667
704
|
provider = judge_provider
|
|
668
|
-
model =
|
|
669
|
-
options =
|
|
705
|
+
model = judge_pinned_model(provider)
|
|
706
|
+
options = owner_provider_options(provider).dup
|
|
670
707
|
options[:model] = model if model
|
|
671
|
-
options.merge!(owner_provider_options(provider))
|
|
672
708
|
|
|
673
709
|
Class.new(ActiveAgent::Base) do
|
|
674
710
|
define_singleton_method(:name) { JUDGE_AGENT_NAME }
|