actionagent 1.9.1 → 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 +56 -6
- 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/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +2 -2
- data/app/jobs/action_agent/code_session_job.rb +19 -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/agent.rb +54 -1
- data/app/models/action_agent/code_session.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +37 -5
- 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_fix_comparison.rb +4 -1
- data/app/services/action_agent/evaluation_report_export.rb +175 -0
- data/app/services/action_agent/evaluation_report_import.rb +47 -19
- data/app/services/action_agent/evaluation_runner_service.rb +106 -14
- 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 +381 -3
- 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 +50 -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 +15 -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
|
|
@@ -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"
|
|
@@ -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!
|
|
@@ -568,12 +599,17 @@ module ActionAgent
|
|
|
568
599
|
# results as the application reported them — no cost is estimated here,
|
|
569
600
|
# so the summaries stored stay the application's own figures; the API
|
|
570
601
|
# adds the estimates when it serves the run (EvaluationRun#model_summaries).
|
|
602
|
+
#
|
|
603
|
+
# The comparison is worked out here from the stored results too, rather
|
|
604
|
+
# than taken from the payload, like the summaries.
|
|
571
605
|
def summarized_scores(run)
|
|
572
606
|
rebuilt = run.to_report(estimate: false)
|
|
607
|
+
comparison = rebuilt.comparison if rebuilt.respond_to?(:comparison)
|
|
573
608
|
rebuilt.criterion_scores.merge(
|
|
574
609
|
"_models" => rebuilt.summary_by_model,
|
|
575
|
-
"_recommendations" => rebuilt.recommendations
|
|
576
|
-
|
|
610
|
+
"_recommendations" => rebuilt.recommendations,
|
|
611
|
+
"_comparison" => comparison
|
|
612
|
+
).compact.merge(recorded_scores)
|
|
577
613
|
end
|
|
578
614
|
|
|
579
615
|
# --- validation ------------------------------------------------------------
|
|
@@ -810,13 +846,5 @@ module ActionAgent
|
|
|
810
846
|
else value
|
|
811
847
|
end
|
|
812
848
|
end
|
|
813
|
-
|
|
814
|
-
def canonical(value)
|
|
815
|
-
case value
|
|
816
|
-
when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
|
|
817
|
-
when Array then value.map { |child| canonical(child) }
|
|
818
|
-
else value
|
|
819
|
-
end
|
|
820
|
-
end
|
|
821
849
|
end
|
|
822
850
|
end
|
|
@@ -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
|
|
@@ -31,6 +33,10 @@ module ActionAgent
|
|
|
31
33
|
def call
|
|
32
34
|
run = @evaluation.evaluation_runs.create!(status: :running)
|
|
33
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
|
+
|
|
34
40
|
# judge_defined evaluations author their KPI criteria on first run.
|
|
35
41
|
ensure_judge_defined_kpis! if @evaluation.judge_defined?
|
|
36
42
|
|
|
@@ -66,6 +72,7 @@ module ActionAgent
|
|
|
66
72
|
|
|
67
73
|
scores["_cohorts"] = cohort_summaries(samples, per_sample_scores) if samples.any?
|
|
68
74
|
scores["_judge_usage"] = judge_usage if judge_usage
|
|
75
|
+
scores["_judge"] = judge_descriptor if judge_usage
|
|
69
76
|
|
|
70
77
|
run.update!(
|
|
71
78
|
status: :complete,
|
|
@@ -80,19 +87,52 @@ module ActionAgent
|
|
|
80
87
|
raise
|
|
81
88
|
end
|
|
82
89
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# when the owner configured a host; nil when none. The judge runs
|
|
86
|
-
# `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.
|
|
87
92
|
def judge_provider
|
|
88
|
-
@judge_provider ||=
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
92
108
|
end
|
|
93
109
|
|
|
94
110
|
private
|
|
95
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
|
+
|
|
96
136
|
# Scores each sample-based criterion once per candidate model cohort and
|
|
97
137
|
# asks the judge for a comparative verdict — "haiku vs qwen, judged".
|
|
98
138
|
def score_comparison(run, sample_criteria, telemetry_criteria)
|
|
@@ -136,6 +176,7 @@ module ActionAgent
|
|
|
136
176
|
[ model, cohort_summary(samples, per_model_sample_scores[model] || {}) ]
|
|
137
177
|
end
|
|
138
178
|
scores["_judge_usage"] = judge_usage if judge_usage
|
|
179
|
+
scores["_judge"] = judge_descriptor if judge_usage
|
|
139
180
|
|
|
140
181
|
run.update!(
|
|
141
182
|
status: :complete,
|
|
@@ -236,7 +277,8 @@ module ActionAgent
|
|
|
236
277
|
# first. Persisted as scores["_judge_usage"]:
|
|
237
278
|
#
|
|
238
279
|
# { "calls", "input_tokens", "output_tokens", "cost", "model",
|
|
239
|
-
# "by_kind" => { "score" => n, "recommend" => n, "verdict" => n, "define" => n }
|
|
280
|
+
# "by_kind" => { "score" => n, "recommend" => n, "verdict" => n, "define" => n },
|
|
281
|
+
# "models" => { "<model that answered>" => n } }
|
|
240
282
|
#
|
|
241
283
|
# nil until the judge has been asked something, so a rules-only run
|
|
242
284
|
# records no judge at all rather than a judge that cost nothing.
|
|
@@ -263,13 +305,42 @@ module ActionAgent
|
|
|
263
305
|
model = (response.respond_to?(:model) && response.model.presence) || @evaluation.judge_model.presence
|
|
264
306
|
cost = ModelPricing.estimate(model: model, provider: judge_provider, input_tokens: input_tokens, output_tokens: output_tokens)
|
|
265
307
|
|
|
266
|
-
@judge_usage ||= { "calls" => 0, "input_tokens" => 0, "output_tokens" => 0, "cost" => nil, "model" => nil, "by_kind" => {} }
|
|
308
|
+
@judge_usage ||= { "calls" => 0, "input_tokens" => 0, "output_tokens" => 0, "cost" => nil, "model" => nil, "by_kind" => {}, "models" => {} }
|
|
267
309
|
@judge_usage["calls"] += 1
|
|
268
310
|
@judge_usage["input_tokens"] += input_tokens
|
|
269
311
|
@judge_usage["output_tokens"] += output_tokens
|
|
270
312
|
@judge_usage["cost"] = (@judge_usage["cost"] || 0.0) + cost if cost
|
|
271
313
|
@judge_usage["model"] ||= model
|
|
272
314
|
@judge_usage["by_kind"][kind.to_s] = @judge_usage["by_kind"].fetch(kind.to_s, 0) + 1
|
|
315
|
+
(@judge_usage["models"] ||= {})[model] = @judge_usage["models"].fetch(model, 0) + 1 if model
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# The judge as a run records it (scores["_judge"]): the agent class it
|
|
319
|
+
# runs as, its provider, the model it asks for and where that setting
|
|
320
|
+
# came from, and the models that answered. A judge with no model pinned
|
|
321
|
+
# runs on its provider's default, and OpenRouter's (openrouter/auto) is a
|
|
322
|
+
# router that can answer each call with a different model, so "answered"
|
|
323
|
+
# is what actually judged. nil when no provider has credentials.
|
|
324
|
+
def judge_descriptor
|
|
325
|
+
return nil unless judge_provider
|
|
326
|
+
|
|
327
|
+
{
|
|
328
|
+
"agent" => JUDGE_AGENT_NAME,
|
|
329
|
+
"provider" => judge_provider.to_s,
|
|
330
|
+
"model" => judge_requested_model,
|
|
331
|
+
"model_source" => judge_model_source,
|
|
332
|
+
"answered" => judge_usage&.dig("models").to_h
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# How a run names its judge: the model it asked for, else the one model
|
|
337
|
+
# that answered, else the provider's default.
|
|
338
|
+
def judge_display_label
|
|
339
|
+
return nil unless judge_provider
|
|
340
|
+
return judge_requested_model if judge_requested_model
|
|
341
|
+
|
|
342
|
+
answered = judge_usage&.dig("models").to_h.keys
|
|
343
|
+
answered.one? ? answered.first : "#{judge_provider} default"
|
|
273
344
|
end
|
|
274
345
|
|
|
275
346
|
# Every token the judge was billed for reading. Anthropic reports the
|
|
@@ -602,6 +673,28 @@ module ActionAgent
|
|
|
602
673
|
@owner_provider_options[name.to_s] ||= ProviderCredentials.resolve(owner: owner, actor: nil, provider: name).options
|
|
603
674
|
end
|
|
604
675
|
|
|
676
|
+
# The model the judge asks for, in the order judge_class applies them:
|
|
677
|
+
# the evaluation's judge_model, the owner's provider key, the host's
|
|
678
|
+
# config/active_agent.yml. nil leaves the choice to the provider.
|
|
679
|
+
def judge_requested_model
|
|
680
|
+
judge_model_setting&.last
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def judge_model_source
|
|
684
|
+
judge_model_setting&.first || "provider_default"
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def judge_model_setting
|
|
688
|
+
return @judge_model_setting if defined?(@judge_model_setting)
|
|
689
|
+
|
|
690
|
+
config = ActiveAgent.configuration[judge_provider]
|
|
691
|
+
@judge_model_setting = {
|
|
692
|
+
"evaluation" => judge_pinned_model,
|
|
693
|
+
"provider_key" => owner_provider_options(judge_provider)[:model],
|
|
694
|
+
"config" => (config[:model] if config.respond_to?(:[]))
|
|
695
|
+
}.find { |_source, model| model.present? }&.then { |source, model| [ source, model.to_s ] }
|
|
696
|
+
end
|
|
697
|
+
|
|
605
698
|
def owner
|
|
606
699
|
@owner ||= @evaluation&.agent&.owner
|
|
607
700
|
end
|
|
@@ -609,13 +702,12 @@ module ActionAgent
|
|
|
609
702
|
def judge_class
|
|
610
703
|
@judge_class ||= begin
|
|
611
704
|
provider = judge_provider
|
|
612
|
-
model =
|
|
613
|
-
options =
|
|
705
|
+
model = judge_pinned_model(provider)
|
|
706
|
+
options = owner_provider_options(provider).dup
|
|
614
707
|
options[:model] = model if model
|
|
615
|
-
options.merge!(owner_provider_options(provider))
|
|
616
708
|
|
|
617
709
|
Class.new(ActiveAgent::Base) do
|
|
618
|
-
define_singleton_method(:name) {
|
|
710
|
+
define_singleton_method(:name) { JUDGE_AGENT_NAME }
|
|
619
711
|
generate_with provider, **options
|
|
620
712
|
end
|
|
621
713
|
end
|