actionagent 1.6.4 → 1.7.0
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 +5 -0
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +55 -55
- data/app/controllers/action_agent/api/agents_controller.rb +8 -2
- data/app/controllers/action_agent/api/base_controller.rb +20 -4
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +0 -11
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +134 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +52 -11
- data/app/controllers/action_agent/api/interactions_controller.rb +2 -3
- data/app/controllers/action_agent/api/mcp_controller.rb +3 -1
- data/app/controllers/action_agent/api/provider_models_controller.rb +4 -1
- data/app/controllers/action_agent/api/trace_reports_controller.rb +20 -1
- data/app/controllers/action_agent/api/traces_controller.rb +8 -57
- data/app/controllers/action_agent/application_controller.rb +4 -0
- data/app/controllers/concerns/action_agent/api/ingest_authentication.rb +94 -0
- data/app/models/action_agent/agent.rb +71 -3
- data/app/models/action_agent/application_record.rb +4 -0
- data/app/models/action_agent/evaluation_run.rb +61 -13
- data/app/models/action_agent/telemetry_trace.rb +4 -3
- data/app/models/concerns/action_agent/ownable.rb +18 -6
- data/app/queries/action_agent/metrics_report.rb +1 -1
- data/app/services/action_agent/agent_execution_service.rb +49 -0
- data/app/services/action_agent/agent_sync.rb +136 -0
- data/app/services/action_agent/agent_tool_roster.rb +1 -1
- data/app/services/action_agent/evaluation_report_import.rb +743 -0
- data/app/services/action_agent/evaluation_runner_service.rb +119 -10
- data/app/services/action_agent/scenario_evaluation_runner.rb +8 -3
- data/config/routes.rb +5 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +110 -13
- data/lib/generators/action_agent/install_generator.rb +26 -3
- data/lib/generators/action_agent/templates/action_agent.rb.erb +19 -3
- data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +18 -13
- data/lib/generators/action_agent/templates/add_evaluation_report_identity.rb.erb +54 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +34 -0
- data/lib/generators/action_agent/templates/ensure_agent_release_columns.rb.erb +51 -0
- metadata +8 -2
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module ActionAgent
|
|
7
|
+
# Used to store an evaluation an application ran itself, published as the
|
|
8
|
+
# version-1 envelope ActiveAgent::Evals::Publisher sends, in the engine's own
|
|
9
|
+
# evaluation tables, so the Evaluations view shows it the way it shows a run
|
|
10
|
+
# the dashboard executed. It never executes the application's agent.
|
|
11
|
+
#
|
|
12
|
+
# The tenant is the account the ingest key resolved to on a multi-tenant
|
|
13
|
+
# install, and nil on a single-tenant one. The report lands as:
|
|
14
|
+
# agent — the observed agent for the envelope's `source` and
|
|
15
|
+
# `agent_name`, owned where the tenant's traced agents are
|
|
16
|
+
# (see #owner)
|
|
17
|
+
# evaluation — that agent's evaluation for the `suite` and the report's
|
|
18
|
+
# scope (SCOPE_KEYS in its metadata), named for both
|
|
19
|
+
# scenarios — one per reported scenario key, updated to the reported prompt
|
|
20
|
+
# run — one complete EvaluationRun per tenant and run_id, with one
|
|
21
|
+
# EvaluationScenarioResult per scenario and model
|
|
22
|
+
#
|
|
23
|
+
# The run's per-model summary, criterion scores and recommendations are
|
|
24
|
+
# computed from the stored results, not taken from the report. The judge's
|
|
25
|
+
# verdict and label are taken from it.
|
|
26
|
+
#
|
|
27
|
+
# An identical retry returns the stored run, before anything is asked of the
|
|
28
|
+
# `admit` callable. Different content under a run_id already stored raises
|
|
29
|
+
# Conflict. Imports for one owner run one at a time under an advisory lock
|
|
30
|
+
# (OwnerLock), so concurrent first deliveries find or create one agent,
|
|
31
|
+
# evaluation and scenario set, and the unique index on the run's tenant and
|
|
32
|
+
# run_id settles any delivery the lock does not serialize.
|
|
33
|
+
class EvaluationReportImport
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
# The payload is not a report the engine can store as sent; 422.
|
|
36
|
+
class Invalid < Error; end
|
|
37
|
+
# Different content under a run_id already stored; 409.
|
|
38
|
+
class Conflict < Error; end
|
|
39
|
+
# Storing the report needs an operator to act first; 403.
|
|
40
|
+
class Refused < Error; end
|
|
41
|
+
# A cap on stored records was reached; 403.
|
|
42
|
+
class LimitExceeded < Refused; end
|
|
43
|
+
# Another import holds the owner's lock past OwnerLock::TIMEOUT; 503.
|
|
44
|
+
class Busy < Error; end
|
|
45
|
+
# The `admit` callable refused a new report; 429.
|
|
46
|
+
class Denied < Error
|
|
47
|
+
attr_reader :denial
|
|
48
|
+
|
|
49
|
+
def initialize(denial)
|
|
50
|
+
@denial = denial
|
|
51
|
+
super(denial.is_a?(Hash) ? denial[:error] || denial["error"] : denial.to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
MAX_BYTES = 2.megabytes
|
|
56
|
+
MAX_RESULTS = 1000
|
|
57
|
+
MAX_MODELS = 50
|
|
58
|
+
MAX_TEXT = MAX_BYTES
|
|
59
|
+
# Scenarios one evaluation may hold, and evaluations one agent may hold,
|
|
60
|
+
# across every report published to it.
|
|
61
|
+
MAX_SCENARIOS_PER_EVALUATION = 2000
|
|
62
|
+
MAX_EVALUATIONS_PER_AGENT = 100
|
|
63
|
+
# The longest evaluation name a string column holds on every supported
|
|
64
|
+
# database (MySQL's VARCHAR(255)).
|
|
65
|
+
MAX_EVALUATION_NAME = 255
|
|
66
|
+
# The most a TEXT column holds on MySQL. A prompt, error or recommendation
|
|
67
|
+
# longer than this is stored cut to it; the scenario snapshot and the
|
|
68
|
+
# diagnosis, which are JSON, keep the whole text.
|
|
69
|
+
TEXT_BYTES = 65_535
|
|
70
|
+
IDENTIFIER_PATTERN = /\A[^[:cntrl:]]{1,200}\z/
|
|
71
|
+
AGENT_NAME_PATTERN = /\A[^[:cntrl:]]{2,100}\z/
|
|
72
|
+
TRACE_PATTERN = /\A[a-zA-Z0-9_-]{1,128}\z/
|
|
73
|
+
SCOPE_PATTERN = %r{\A[\w .:/@-]{1,100}\z}
|
|
74
|
+
STATUSES = %w[passed failed errored].freeze
|
|
75
|
+
# Report metadata that tells one evaluation of a suite from another, in the
|
|
76
|
+
# order it is written into the evaluation's name.
|
|
77
|
+
SCOPE_KEYS = %w[scope environment role].freeze
|
|
78
|
+
# The engine stores at most this much of an answer (ScenarioEvaluationRunner#persist).
|
|
79
|
+
OUTPUT_BYTES = 20_000
|
|
80
|
+
# The largest value each numeric result column holds.
|
|
81
|
+
NUMERIC_LIMITS = {
|
|
82
|
+
"duration_ms" => 2_147_483_647,
|
|
83
|
+
"input_tokens" => 2_147_483_647,
|
|
84
|
+
"output_tokens" => 2_147_483_647,
|
|
85
|
+
"cost" => 999_999
|
|
86
|
+
}.freeze
|
|
87
|
+
# What an observed agent created from a report records as its source.
|
|
88
|
+
AGENT_SOURCE = "evaluation-report"
|
|
89
|
+
# The evaluation_runs columns an import writes that later migrations added.
|
|
90
|
+
REQUIRED_RUN_COLUMNS = %w[external_tenant external_run_id external_report_digest agent_version_id].freeze
|
|
91
|
+
|
|
92
|
+
# Returns `[run, duplicate]`: the stored EvaluationRun, and whether an
|
|
93
|
+
# earlier identical delivery had already stored it. `account` is the
|
|
94
|
+
# tenant, or nil on a single-tenant install.
|
|
95
|
+
#
|
|
96
|
+
# `admit` is asked only for a report that would be stored, never for an
|
|
97
|
+
# identical retry. It returns nil to allow the report, or a denial, which
|
|
98
|
+
# is raised as Denied.
|
|
99
|
+
#
|
|
100
|
+
# The envelope's identities are validated as sent. NUL characters, which
|
|
101
|
+
# PostgreSQL cannot store, are removed from every string inside `report`.
|
|
102
|
+
#
|
|
103
|
+
# Raises Invalid for a payload that is not a valid version-1 report, or an
|
|
104
|
+
# evaluation name the agent already holds for another suite or scope;
|
|
105
|
+
# Conflict for different content under a stored run_id; Refused (and
|
|
106
|
+
# LimitExceeded) when storing needs an operator to act first; Denied; and
|
|
107
|
+
# Busy.
|
|
108
|
+
def self.call(payload:, account: nil, admit: nil)
|
|
109
|
+
new(payload, account, admit).call
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Why this install cannot store reports, or nil when it can: an install
|
|
113
|
+
# with no evaluation tables (generated with --traces-only), or one whose
|
|
114
|
+
# evaluation runs lack a column a later migration adds.
|
|
115
|
+
def self.unavailable_reason
|
|
116
|
+
unless EvaluationRun.table_exists?
|
|
117
|
+
return "This install has no evaluation tables (it was installed with --traces-only), so it cannot store evaluation reports"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
missing = REQUIRED_RUN_COLUMNS - EvaluationRun.column_names
|
|
121
|
+
return if missing.empty?
|
|
122
|
+
|
|
123
|
+
"Evaluation runs are missing #{missing.join(', ')}: run `bin/rails generate action_agent:install --skip` " \
|
|
124
|
+
"and `bin/rails db:migrate`, then restart"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def initialize(payload, account, admit)
|
|
128
|
+
@payload = payload
|
|
129
|
+
@account = account
|
|
130
|
+
@admit = admit
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def call
|
|
134
|
+
validate!
|
|
135
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@payload)))
|
|
136
|
+
existing = stored_run
|
|
137
|
+
return [ identical!(existing, digest), true ] if existing
|
|
138
|
+
|
|
139
|
+
attempts = 0
|
|
140
|
+
begin
|
|
141
|
+
OwnerLock.new(EvaluationRun.connection, owner_lock_key).synchronize do
|
|
142
|
+
EvaluationRun.transaction(requires_new: true) do
|
|
143
|
+
existing = stored_run
|
|
144
|
+
next [ identical!(existing, digest), true ] if existing
|
|
145
|
+
|
|
146
|
+
admit!
|
|
147
|
+
[ import(digest), false ]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
rescue ActiveRecord::RecordNotUnique
|
|
151
|
+
# A concurrent delivery committed this run, or an agent, evaluation or
|
|
152
|
+
# scenario this import was creating, first. Reading again sees it.
|
|
153
|
+
existing = stored_run
|
|
154
|
+
return [ identical!(existing, digest), true ] if existing
|
|
155
|
+
|
|
156
|
+
attempts += 1
|
|
157
|
+
retry if attempts < 2
|
|
158
|
+
raise
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A lock held for the duration of a block, keyed by a string, on the
|
|
163
|
+
# database the engine's tables live in: a transaction-scoped advisory lock
|
|
164
|
+
# on PostgreSQL, a named lock on MySQL, and nothing on SQLite, which
|
|
165
|
+
# already runs one write transaction at a time. Raises Busy when MySQL
|
|
166
|
+
# cannot take the lock within TIMEOUT seconds.
|
|
167
|
+
class OwnerLock
|
|
168
|
+
TIMEOUT = 30
|
|
169
|
+
|
|
170
|
+
def initialize(connection, key)
|
|
171
|
+
@connection = connection
|
|
172
|
+
@key = key
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def synchronize(&block)
|
|
176
|
+
case @connection.adapter_name.to_s.downcase
|
|
177
|
+
when /postgres/ then postgres_lock(&block)
|
|
178
|
+
when /mysql|trilogy/ then mysql_lock(&block)
|
|
179
|
+
else yield
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# Held until the transaction it is taken in ends. The two-integer form
|
|
186
|
+
# keeps it out of the single-bigint keyspace Rails' migration lock uses.
|
|
187
|
+
def postgres_lock
|
|
188
|
+
@connection.transaction(requires_new: true) do
|
|
189
|
+
@connection.execute("SELECT pg_advisory_xact_lock(#{signed(Zlib.crc32('action_agent'))}, #{signed(Zlib.crc32(@key))})")
|
|
190
|
+
yield
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Held by the connection until released, so it is released after the
|
|
195
|
+
# block's transaction has committed.
|
|
196
|
+
def mysql_lock
|
|
197
|
+
name = @connection.quote("action_agent:#{Digest::SHA256.hexdigest(@key).first(40)}")
|
|
198
|
+
acquired = @connection.select_value("SELECT GET_LOCK(#{name}, #{TIMEOUT})")
|
|
199
|
+
raise Busy, "Another evaluation report for this agent is being stored; retry shortly" unless acquired.to_i == 1
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
yield
|
|
203
|
+
ensure
|
|
204
|
+
@connection.select_value("SELECT RELEASE_LOCK(#{name})")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def signed(crc)
|
|
209
|
+
crc >= 2**31 ? crc - 2**32 : crc
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def report
|
|
216
|
+
@report ||= without_nul(@payload["report"])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def results
|
|
220
|
+
report["results"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def metadata
|
|
224
|
+
report["metadata"] || {}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The value of the run's external_tenant: the tenant's id, or "" with no
|
|
228
|
+
# tenant, because a unique index never treats two NULLs as equal.
|
|
229
|
+
def tenant_key
|
|
230
|
+
@account ? @account.id.to_s : ""
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def stored_run
|
|
234
|
+
EvaluationRun.find_by(external_tenant: tenant_key, external_run_id: @payload["run_id"])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def identical!(run, digest)
|
|
238
|
+
raise Conflict, "run_id already exists with a different report" unless run.external_report_digest == digest
|
|
239
|
+
|
|
240
|
+
run
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def admit!
|
|
244
|
+
denial = @admit&.call
|
|
245
|
+
raise Denied, denial if denial.present?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def import(digest)
|
|
249
|
+
agent = find_or_create_agent
|
|
250
|
+
evaluation = find_or_initialize_evaluation(agent)
|
|
251
|
+
scenarios = upsert_scenarios(evaluation)
|
|
252
|
+
run = evaluation.evaluation_runs.create!(
|
|
253
|
+
external_tenant: tenant_key,
|
|
254
|
+
external_run_id: @payload["run_id"],
|
|
255
|
+
external_report_digest: digest,
|
|
256
|
+
status: :complete,
|
|
257
|
+
selection: selection,
|
|
258
|
+
scores: recorded_scores,
|
|
259
|
+
samples_evaluated: results.size,
|
|
260
|
+
samples_passed: results.count { |result| result["status"] == "passed" },
|
|
261
|
+
completed_at: Time.current
|
|
262
|
+
)
|
|
263
|
+
results.each { |result| persist(run, scenarios.fetch(result["scenario_key"]), result) }
|
|
264
|
+
run.update!(scores: summarized_scores(run))
|
|
265
|
+
evaluation.touch
|
|
266
|
+
agent.update_columns(last_observed_at: run.completed_at, updated_at: Time.current)
|
|
267
|
+
run
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- ownership -------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
# Who a newly observed agent belongs to. Whatever the host's
|
|
273
|
+
# trace_owner_resolver returns for a trace of this tenant, when it has
|
|
274
|
+
# one, so a report's agent lands where the tenant's traced agents do.
|
|
275
|
+
# Otherwise the tenant itself, or nobody on a single-tenant install, as
|
|
276
|
+
# AgentRegistrar decides for a trace.
|
|
277
|
+
#
|
|
278
|
+
# Raises Refused when a multi-tenant resolver returns nil, which would
|
|
279
|
+
# place the agent in no tenant's dashboard.
|
|
280
|
+
def owner
|
|
281
|
+
return @owner if defined?(@owner)
|
|
282
|
+
|
|
283
|
+
resolver = ActionAgent.trace_owner_resolver
|
|
284
|
+
@owner = resolver ? resolver.call(tenant_trace) : @account
|
|
285
|
+
if @owner.nil? && ActionAgent.multi_tenant?
|
|
286
|
+
raise Refused, "ActionAgent.trace_owner_resolver returned no owner for the publishing tenant"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
@owner
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# An unsaved trace from the publishing application, for the host's
|
|
293
|
+
# trace_owner_resolver: the tenant as its account, and the envelope's
|
|
294
|
+
# source and agent_name as its service and agent class.
|
|
295
|
+
def tenant_trace
|
|
296
|
+
ActionAgent.trace_model.new(service_name: @payload["source"], agent_class: @payload["agent_name"]).tap do |trace|
|
|
297
|
+
trace.account = @account if @account && trace.respond_to?(:account=)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Imports that could create the same agent share a lock: the owner's
|
|
302
|
+
# agents are where find_or_create_agent looks.
|
|
303
|
+
def owner_lock_key
|
|
304
|
+
owner ? "evaluation_report:#{owner.class.name}:#{owner.id}" : "evaluation_report:install"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The owner's agents. Every agent when the install has no owner to scope
|
|
308
|
+
# to, which is how AgentRegistrar reads a single-tenant dashboard.
|
|
309
|
+
def owner_agents
|
|
310
|
+
owner.nil? && !ActionAgent.multi_tenant? ? Agent.all : Agent.for_owner(owner)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Whether an agent also records the tenant in account_id. Not when agents
|
|
314
|
+
# are owned by account: the owner already is the account the agent
|
|
315
|
+
# belongs to, which the host's resolver may place outside the tenant.
|
|
316
|
+
def records_tenant?
|
|
317
|
+
@account.present? && Agent.owner_association != :account
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# --- records ---------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
# Keyed with no action, unlike the per-action agents trace ingest observes:
|
|
323
|
+
# the report evaluates the agent as a whole.
|
|
324
|
+
def find_or_create_agent
|
|
325
|
+
find_agent || create_agent(agent_slug)
|
|
326
|
+
rescue ActiveRecord::RecordNotUnique
|
|
327
|
+
# Another owner's concurrent first import took the slug.
|
|
328
|
+
find_agent || create_agent("#{slug_base}-#{SecureRandom.hex(3)}")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def agent_identity
|
|
332
|
+
{ service_name: @payload["source"], agent_class_name: @payload["agent_name"], action_name: nil }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Narrowed to the tenant when agents are owned by a user, who may belong
|
|
336
|
+
# to another tenant as well.
|
|
337
|
+
def find_agent
|
|
338
|
+
agents = owner_agents.observed_agents
|
|
339
|
+
agents = agents.where(account_id: @account.id) if records_tenant?
|
|
340
|
+
agents.find_by(agent_identity)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Created inside a savepoint, so a slug collision can be retried without
|
|
344
|
+
# aborting the import's transaction on PostgreSQL.
|
|
345
|
+
def create_agent(slug)
|
|
346
|
+
if owner_agents.observed_agents.count >= AgentRegistrar::MAX_OBSERVED_PER_OWNER
|
|
347
|
+
raise LimitExceeded, "Observed agent limit reached (#{AgentRegistrar::MAX_OBSERVED_PER_OWNER}); " \
|
|
348
|
+
"remove observed agents on the dashboard before publishing a new one"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
Agent.transaction(requires_new: true) do
|
|
352
|
+
now = Time.current
|
|
353
|
+
agent = Agent.new(
|
|
354
|
+
agent_identity.merge(
|
|
355
|
+
name: @payload["agent_name"],
|
|
356
|
+
slug: slug,
|
|
357
|
+
status: :observed,
|
|
358
|
+
source: AGENT_SOURCE,
|
|
359
|
+
description: "Evaluation reports published by #{@payload['source']}",
|
|
360
|
+
provider: results.first["provider"],
|
|
361
|
+
model: results.first["model"],
|
|
362
|
+
instructions: "",
|
|
363
|
+
tools: [],
|
|
364
|
+
first_observed_at: now,
|
|
365
|
+
last_observed_at: now
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
agent.owner = owner
|
|
369
|
+
agent.account_id = @account.id if records_tenant?
|
|
370
|
+
agent.save!
|
|
371
|
+
agent
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Slugs are checked globally, as AgentRegistrar#observed_slug does, since a
|
|
376
|
+
# host may hold a global unique index on them.
|
|
377
|
+
def agent_slug
|
|
378
|
+
Agent.exists?(slug: slug_base) ? "#{slug_base}-#{SecureRandom.hex(3)}" : slug_base
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Cut to 200 characters so the suffixed slug fits a VARCHAR(255).
|
|
382
|
+
def slug_base
|
|
383
|
+
@slug_base ||= [ @payload["source"], @payload["agent_name"] ].join("-").parameterize.first(200).presence || "external-agent"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# An evaluation of this name that no report created, or that another source,
|
|
387
|
+
# suite or scope created, is not this report's to add to.
|
|
388
|
+
def find_or_initialize_evaluation(agent)
|
|
389
|
+
evaluation = agent.evaluations.find_or_initialize_by(name: evaluation_name)
|
|
390
|
+
unless evaluation.new_record?
|
|
391
|
+
return evaluation if evaluation.config["external"] == external_config
|
|
392
|
+
|
|
393
|
+
raise Invalid, "The agent already has an evaluation named #{evaluation_name} that this report does not " \
|
|
394
|
+
"belong to; publish under another suite or scope"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
if agent.evaluations.count >= MAX_EVALUATIONS_PER_AGENT
|
|
398
|
+
raise LimitExceeded, "Evaluation limit reached (#{MAX_EVALUATIONS_PER_AGENT} for this agent); " \
|
|
399
|
+
"remove evaluations on the dashboard before publishing a new suite or scope"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
evaluation.assign_attributes(
|
|
403
|
+
judge_kind: judge_label ? "llm" : "rules",
|
|
404
|
+
judge_model: judge_label,
|
|
405
|
+
criteria: [],
|
|
406
|
+
config: { "external" => external_config }
|
|
407
|
+
)
|
|
408
|
+
evaluation
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def external_config
|
|
412
|
+
{ "source" => @payload["source"], "suite" => @payload["suite"], "scope" => scope }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# "orders (eu, support)" for a report whose metadata names a scope and a
|
|
416
|
+
# role; the bare suite for one with no scope.
|
|
417
|
+
def evaluation_name
|
|
418
|
+
values = scope.values
|
|
419
|
+
values.empty? ? @payload["suite"] : "#{@payload['suite']} (#{values.join(', ')})"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def scope
|
|
423
|
+
SCOPE_KEYS.filter_map { |key| [ key, metadata[key] ] if metadata[key].present? }.to_h
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# The judge that scored the report, or nil when it was scored on rules alone.
|
|
427
|
+
# The framework's pass-rate ranking names itself as the judge of a verdict no
|
|
428
|
+
# model wrote, which is not a judge.
|
|
429
|
+
def judge_label
|
|
430
|
+
label = report["judge"]
|
|
431
|
+
label if label.present? && label != ActiveAgent::Evals::Report::PASS_RATE_JUDGE
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Adds the scenarios the evaluation lacks and updates each reported one to
|
|
435
|
+
# the prompt and group it ran with. Reads only the reported scenarios;
|
|
436
|
+
# the ones the report did not run are left as they are. Returns the
|
|
437
|
+
# reported scenarios by key.
|
|
438
|
+
#
|
|
439
|
+
# Raises LimitExceeded when the evaluation would hold more than
|
|
440
|
+
# MAX_SCENARIOS_PER_EVALUATION scenarios.
|
|
441
|
+
def upsert_scenarios(evaluation)
|
|
442
|
+
keys = reported_scenarios.map { |scenario| scenario["key"] }
|
|
443
|
+
by_key = evaluation.new_record? ? {} : evaluation.scenarios.where(key: keys).index_by(&:key)
|
|
444
|
+
added = keys.size - by_key.size
|
|
445
|
+
held = evaluation.new_record? ? 0 : evaluation.scenarios.count
|
|
446
|
+
if held + added > MAX_SCENARIOS_PER_EVALUATION
|
|
447
|
+
raise LimitExceeded, "Scenario limit reached (#{MAX_SCENARIOS_PER_EVALUATION} per evaluation): #{evaluation_name} " \
|
|
448
|
+
"holds #{held} and this report adds #{added}; remove scenarios on the dashboard or publish " \
|
|
449
|
+
"under another suite or scope"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
next_position = evaluation.new_record? ? 0 : (evaluation.scenarios.maximum(:position)&.succ || 0)
|
|
453
|
+
reported_scenarios.each do |attributes|
|
|
454
|
+
unless by_key.key?(attributes["key"])
|
|
455
|
+
by_key[attributes["key"]] = evaluation.scenarios.build(key: attributes["key"], position: next_position)
|
|
456
|
+
next_position += 1
|
|
457
|
+
end
|
|
458
|
+
by_key[attributes["key"]].assign_attributes(prompt: truncated(attributes["prompt"], TEXT_BYTES), group: attributes["group"])
|
|
459
|
+
end
|
|
460
|
+
# A new evaluation is valid without criteria only once it has scenarios, so
|
|
461
|
+
# it is saved with them; an existing one saves the scenarios it gained.
|
|
462
|
+
evaluation.save!
|
|
463
|
+
by_key.each_value { |scenario| scenario.save! if scenario.changed? }
|
|
464
|
+
by_key
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def reported_scenarios
|
|
468
|
+
@reported_scenarios ||= results.uniq { |result| result["scenario_key"] }.map do |result|
|
|
469
|
+
{ "key" => result["scenario_key"], "prompt" => result["prompt"].presence || result["scenario_key"], "group" => result["group"] }
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def persist(run, scenario, result)
|
|
474
|
+
run.scenario_results.create!(
|
|
475
|
+
scenario: scenario,
|
|
476
|
+
model: result["model"],
|
|
477
|
+
provider: result["provider"],
|
|
478
|
+
status: result["status"],
|
|
479
|
+
score: result["score"],
|
|
480
|
+
scores: result["scores"] || {},
|
|
481
|
+
output: truncated(result["answer"], OUTPUT_BYTES),
|
|
482
|
+
tool_calls: result["tool_calls"] || [],
|
|
483
|
+
duration_ms: result["duration_ms"],
|
|
484
|
+
input_tokens: result["input_tokens"],
|
|
485
|
+
output_tokens: result["output_tokens"],
|
|
486
|
+
cost: result["cost"],
|
|
487
|
+
fault: result["fault"],
|
|
488
|
+
recommendation: truncated(result["recommendation"], TEXT_BYTES),
|
|
489
|
+
diagnosis: (result["diagnosis"] || {}).merge(
|
|
490
|
+
"_replay_metadata" => result["metadata"] || {},
|
|
491
|
+
"_scenario_snapshot" => {
|
|
492
|
+
"key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
|
|
493
|
+
"position" => scenario.position, "expectations" => {}
|
|
494
|
+
}
|
|
495
|
+
),
|
|
496
|
+
error_message: truncated(result["error"], TEXT_BYTES)
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# The first +bytes+ bytes of +text+, dropping a character the cut splits,
|
|
501
|
+
# or nil for blank text.
|
|
502
|
+
def truncated(text, bytes)
|
|
503
|
+
text.to_s.byteslice(0, bytes).to_s.scrub("").presence
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# The scenarios and models the run covered, in the shape
|
|
507
|
+
# ScenarioEvaluationRunner records, so EvaluationRun#to_report labels each
|
|
508
|
+
# model the way the report did.
|
|
509
|
+
def selection
|
|
510
|
+
{
|
|
511
|
+
"scenario_keys" => reported_scenarios.map { |scenario| scenario["key"] },
|
|
512
|
+
"models" => results.uniq { |result| result["label"] }.map { |result| result.slice("label", "provider", "model") }
|
|
513
|
+
}
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# What the run keeps from the report itself, which EvaluationRun#to_report
|
|
517
|
+
# reads back when it rebuilds the report. `_judge_label` is recorded even
|
|
518
|
+
# when nil, which EvaluationRun#judge_label reads as "rules" instead of
|
|
519
|
+
# falling back to the evaluation's judge.
|
|
520
|
+
def recorded_scores
|
|
521
|
+
{
|
|
522
|
+
"_verdict" => report["verdict"],
|
|
523
|
+
"_selection" => selection,
|
|
524
|
+
"_metadata" => metadata
|
|
525
|
+
}.compact.merge("_judge_label" => judge_label)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# The run's scores in the shape the Evaluations view renders
|
|
529
|
+
# (ScenarioEvaluationRunner#scores_for), summarized from the stored results.
|
|
530
|
+
def summarized_scores(run)
|
|
531
|
+
rebuilt = run.to_report
|
|
532
|
+
rebuilt.criterion_scores.merge(
|
|
533
|
+
"_models" => rebuilt.summary_by_model,
|
|
534
|
+
"_recommendations" => rebuilt.recommendations
|
|
535
|
+
).merge(recorded_scores)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# --- validation ------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
# Checks the envelope's identities as sent, so a control character in one
|
|
541
|
+
# (NUL included) is refused rather than removed, and the receipt echoes
|
|
542
|
+
# the run_id exactly.
|
|
543
|
+
def validate!
|
|
544
|
+
object!(@payload, "payload")
|
|
545
|
+
validate_json!(@payload)
|
|
546
|
+
raise Invalid, "version must be 1" unless @payload["version"] == 1
|
|
547
|
+
|
|
548
|
+
%w[run_id source suite].each { |key| identifier!(@payload[key], key) }
|
|
549
|
+
unless @payload["agent_name"].is_a?(String) && AGENT_NAME_PATTERN.match?(@payload["agent_name"]) && @payload["agent_name"].strip.length >= 2
|
|
550
|
+
raise Invalid, "agent_name must be 2-100 characters without control characters"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
object!(report, "report")
|
|
554
|
+
optional_object!(report["metadata"], "report.metadata")
|
|
555
|
+
SCOPE_KEYS.each { |key| scope_value!(metadata[key], "report.metadata.#{key}") }
|
|
556
|
+
if evaluation_name.length > MAX_EVALUATION_NAME
|
|
557
|
+
raise Invalid, "suite and scope name an evaluation longer than #{MAX_EVALUATION_NAME} characters"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
judge_trace_ids!(metadata["judge_trace_ids"])
|
|
561
|
+
string!(report["judge"], "report.judge", 200)
|
|
562
|
+
object!(report["models"], "report.models")
|
|
563
|
+
raise Invalid, "report.models must contain 1-#{MAX_MODELS} models" unless report["models"].size.between?(1, MAX_MODELS)
|
|
564
|
+
unless results.is_a?(Array) && results.size.between?(1, MAX_RESULTS)
|
|
565
|
+
raise Invalid, "report.results must contain 1-#{MAX_RESULTS} results"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
validate_results!
|
|
569
|
+
validate_verdict!
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def validate_results!
|
|
573
|
+
pairs = Set.new
|
|
574
|
+
result_ids = Set.new
|
|
575
|
+
label_specs = {}
|
|
576
|
+
results.each_with_index do |result, index|
|
|
577
|
+
object!(result, "result #{index}")
|
|
578
|
+
%w[scenario_key label provider model].each { |key| string!(result[key], "result.#{key}", 200, required: true) }
|
|
579
|
+
raise Invalid, "result label #{result['label']} is missing from report.models" unless report["models"].key?(result["label"])
|
|
580
|
+
raise Invalid, "duplicate scenario/model result" unless pairs.add?(result.values_at("scenario_key", "label"))
|
|
581
|
+
|
|
582
|
+
spec = result.values_at("provider", "model")
|
|
583
|
+
raise Invalid, "result label #{result['label']} names more than one provider/model" if label_specs.fetch(result["label"], spec) != spec
|
|
584
|
+
|
|
585
|
+
label_specs[result["label"]] = spec
|
|
586
|
+
raise Invalid, "invalid result status" unless STATUSES.include?(result["status"])
|
|
587
|
+
unless result["fault"].nil? || EvaluationScenarioResult::FAULTS.include?(result["fault"])
|
|
588
|
+
raise Invalid, "unknown fault #{result['fault']}"
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
numeric!(result["score"], "result.score", max: 1)
|
|
592
|
+
optional_object!(result["scores"], "result.scores")
|
|
593
|
+
(result["scores"] || {}).each_value { |score| numeric!(score, "criterion score", max: 1) }
|
|
594
|
+
NUMERIC_LIMITS.each { |key, max| numeric!(result[key], "result.#{key}", max: max) }
|
|
595
|
+
%w[prompt answer error recommendation].each { |key| string!(result[key], "result.#{key}", MAX_TEXT) }
|
|
596
|
+
string!(result["group"], "result.group", 200)
|
|
597
|
+
validate_tool_calls!(result["tool_calls"])
|
|
598
|
+
validate_diagnosis!(result["diagnosis"])
|
|
599
|
+
validate_derived_fields!(result)
|
|
600
|
+
optional_object!(result["metadata"], "result.metadata")
|
|
601
|
+
result_metadata = result["metadata"] || {}
|
|
602
|
+
if result_metadata["result_id"]
|
|
603
|
+
identifier!(result_metadata["result_id"], "result_id")
|
|
604
|
+
raise Invalid, "duplicate result_id" unless result_ids.add?(result_metadata["result_id"])
|
|
605
|
+
end
|
|
606
|
+
trace_id!(result_metadata["trace_id"]) if result_metadata["trace_id"]
|
|
607
|
+
judge_trace_ids!(result_metadata["judge_trace_ids"])
|
|
608
|
+
end
|
|
609
|
+
distinct_specs = label_specs.values.uniq
|
|
610
|
+
raise Invalid, "two model labels name the same provider/model" if distinct_specs.size < label_specs.size
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def validate_tool_calls!(tool_calls)
|
|
614
|
+
return if tool_calls.nil?
|
|
615
|
+
raise Invalid, "result.tool_calls must be an array" unless tool_calls.is_a?(Array)
|
|
616
|
+
|
|
617
|
+
tool_calls.each do |call|
|
|
618
|
+
object!(call, "result.tool_calls entry")
|
|
619
|
+
string!(call["name"], "result.tool_calls name", 200, required: true)
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# `fault` and `recommendation` are the diagnosis's, which is where
|
|
624
|
+
# ActiveAgent::Evals::Result#to_h reads them. The row stores the top-level
|
|
625
|
+
# values and the rebuilt report reads the diagnosis, so a result where
|
|
626
|
+
# they differ would store a run that contradicts itself. Both absent is
|
|
627
|
+
# consistent.
|
|
628
|
+
def validate_derived_fields!(result)
|
|
629
|
+
%w[fault recommendation].each do |key|
|
|
630
|
+
next if result[key] == result["diagnosis"]&.dig(key)
|
|
631
|
+
|
|
632
|
+
raise Invalid, "result.#{key} must equal result.diagnosis.#{key}"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# The diagnosis fields the dashboard reads, in the shapes
|
|
637
|
+
# ActiveAgent::Evals::Diagnosis writes them.
|
|
638
|
+
def validate_diagnosis!(diagnosis)
|
|
639
|
+
return if diagnosis.nil?
|
|
640
|
+
|
|
641
|
+
object!(diagnosis, "result.diagnosis")
|
|
642
|
+
%w[summary recommendation].each { |key| string!(diagnosis[key], "result.diagnosis.#{key}", MAX_TEXT) }
|
|
643
|
+
optional_object!(diagnosis["evidence"], "result.diagnosis.evidence")
|
|
644
|
+
unavailable = diagnosis.dig("evidence", "unavailable")
|
|
645
|
+
unless unavailable.nil? || (unavailable.is_a?(Array) && unavailable.all?(String))
|
|
646
|
+
raise Invalid, "result.diagnosis.evidence.unavailable must be an array of tool names"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
judge = diagnosis["judge"]
|
|
650
|
+
optional_object!(judge, "result.diagnosis.judge")
|
|
651
|
+
return if judge.nil?
|
|
652
|
+
|
|
653
|
+
string!(judge["instruction_change"], "result.diagnosis.judge.instruction_change", MAX_TEXT)
|
|
654
|
+
optional_object!(judge["suggested_tool"], "result.diagnosis.judge.suggested_tool")
|
|
655
|
+
string!(judge.dig("suggested_tool", "name"), "result.diagnosis.judge.suggested_tool.name", 200)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def validate_verdict!
|
|
659
|
+
verdict = report["verdict"]
|
|
660
|
+
return if verdict.nil?
|
|
661
|
+
|
|
662
|
+
object!(verdict, "report.verdict")
|
|
663
|
+
%w[winner judge].each { |key| string!(verdict[key], "report.verdict.#{key}", 200) }
|
|
664
|
+
string!(verdict["rationale"], "report.verdict.rationale", MAX_TEXT)
|
|
665
|
+
return if verdict["winner"].nil? || report["models"].key?(verdict["winner"])
|
|
666
|
+
|
|
667
|
+
raise Invalid, "report.verdict.winner is missing from report.models"
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def object!(value, name)
|
|
671
|
+
raise Invalid, "#{name} must be an object" unless value.is_a?(Hash)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def optional_object!(value, name)
|
|
675
|
+
object!(value, name) unless value.nil?
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def string!(value, name, max, required: false)
|
|
679
|
+
return if value.nil? && !required
|
|
680
|
+
raise Invalid, "#{name} must be a string of at most #{max} characters" unless value.is_a?(String) && value.length <= max
|
|
681
|
+
raise Invalid, "#{name} is required" if required && value.strip.empty?
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def identifier!(value, name)
|
|
685
|
+
raise Invalid, "#{name} must be 1-200 characters without control characters" unless value.is_a?(String) && IDENTIFIER_PATTERN.match?(value)
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def scope_value!(value, name)
|
|
689
|
+
return if value.nil?
|
|
690
|
+
raise Invalid, "#{name} must be 1-100 letters, digits, spaces or . : / @ _ -" unless value.is_a?(String) && SCOPE_PATTERN.match?(value)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def trace_id!(value)
|
|
694
|
+
raise Invalid, "invalid trace ID" unless value.is_a?(String) && TRACE_PATTERN.match?(value)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def judge_trace_ids!(value)
|
|
698
|
+
return if value.nil?
|
|
699
|
+
raise Invalid, "judge_trace_ids must be an array of at most 100 IDs" unless value.is_a?(Array) && value.size <= 100
|
|
700
|
+
|
|
701
|
+
value.each { |trace| trace_id!(trace) }
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def numeric!(value, name, max:)
|
|
705
|
+
return if value.nil?
|
|
706
|
+
raise Invalid, "#{name} must be a finite number between 0 and #{max}" unless value.is_a?(Numeric) && value.finite? && value.between?(0, max)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def validate_json!(value, depth = 0)
|
|
710
|
+
raise Invalid, "payload exceeds maximum nesting depth" if depth > 20
|
|
711
|
+
|
|
712
|
+
case value
|
|
713
|
+
when Hash
|
|
714
|
+
value.each do |key, child|
|
|
715
|
+
string!(key, "object key", 200, required: true)
|
|
716
|
+
validate_json!(child, depth + 1)
|
|
717
|
+
end
|
|
718
|
+
when Array then value.each { |child| validate_json!(child, depth + 1) }
|
|
719
|
+
when String then raise Invalid, "text is not valid UTF-8" unless value.valid_encoding?
|
|
720
|
+
when Numeric then raise Invalid, "non-finite number" unless value.finite?
|
|
721
|
+
when NilClass, TrueClass, FalseClass then nil
|
|
722
|
+
else raise Invalid, "unsupported JSON value"
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def without_nul(value)
|
|
727
|
+
case value
|
|
728
|
+
when Hash then value.to_h { |key, child| [ without_nul(key), without_nul(child) ] }
|
|
729
|
+
when Array then value.map { |child| without_nul(child) }
|
|
730
|
+
when String then value.delete("\u0000")
|
|
731
|
+
else value
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def canonical(value)
|
|
736
|
+
case value
|
|
737
|
+
when Hash then value.keys.sort.to_h { |key| [ key, canonical(value[key]) ] }
|
|
738
|
+
when Array then value.map { |child| canonical(child) }
|
|
739
|
+
else value
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
end
|