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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +55 -55
  5. data/app/controllers/action_agent/api/agents_controller.rb +8 -2
  6. data/app/controllers/action_agent/api/base_controller.rb +20 -4
  7. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +0 -11
  8. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +134 -0
  9. data/app/controllers/action_agent/api/evaluations_controller.rb +52 -11
  10. data/app/controllers/action_agent/api/interactions_controller.rb +2 -3
  11. data/app/controllers/action_agent/api/mcp_controller.rb +3 -1
  12. data/app/controllers/action_agent/api/provider_models_controller.rb +4 -1
  13. data/app/controllers/action_agent/api/trace_reports_controller.rb +20 -1
  14. data/app/controllers/action_agent/api/traces_controller.rb +8 -57
  15. data/app/controllers/action_agent/application_controller.rb +4 -0
  16. data/app/controllers/concerns/action_agent/api/ingest_authentication.rb +94 -0
  17. data/app/models/action_agent/agent.rb +71 -3
  18. data/app/models/action_agent/application_record.rb +4 -0
  19. data/app/models/action_agent/evaluation_run.rb +61 -13
  20. data/app/models/action_agent/telemetry_trace.rb +4 -3
  21. data/app/models/concerns/action_agent/ownable.rb +18 -6
  22. data/app/queries/action_agent/metrics_report.rb +1 -1
  23. data/app/services/action_agent/agent_execution_service.rb +49 -0
  24. data/app/services/action_agent/agent_sync.rb +136 -0
  25. data/app/services/action_agent/agent_tool_roster.rb +1 -1
  26. data/app/services/action_agent/evaluation_report_import.rb +743 -0
  27. data/app/services/action_agent/evaluation_runner_service.rb +119 -10
  28. data/app/services/action_agent/scenario_evaluation_runner.rb +8 -3
  29. data/config/routes.rb +5 -0
  30. data/lib/action_agent/version.rb +1 -1
  31. data/lib/action_agent.rb +110 -13
  32. data/lib/generators/action_agent/install_generator.rb +26 -3
  33. data/lib/generators/action_agent/templates/action_agent.rb.erb +19 -3
  34. data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +18 -13
  35. data/lib/generators/action_agent/templates/add_evaluation_report_identity.rb.erb +54 -0
  36. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +34 -0
  37. data/lib/generators/action_agent/templates/ensure_agent_release_columns.rb.erb +51 -0
  38. 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