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
@@ -55,6 +55,9 @@ module ActionAgent
55
55
  scores[criterion["key"]] = stats
56
56
  end
57
57
 
58
+ scores["_cohorts"] = cohort_summaries(samples, per_sample_scores) if samples.any?
59
+ scores["_judge_usage"] = judge_usage if judge_usage
60
+
58
61
  run.update!(
59
62
  status: :complete,
60
63
  scores: scores,
@@ -109,6 +112,11 @@ module ActionAgent
109
112
  scores["_verdict"] = verdict if verdict
110
113
  end
111
114
 
115
+ scores["_cohorts"] = active.to_h do |model, samples|
116
+ [ model, cohort_summary(samples, per_model_sample_scores[model] || {}) ]
117
+ end
118
+ scores["_judge_usage"] = judge_usage if judge_usage
119
+
112
120
  run.update!(
113
121
  status: :complete,
114
122
  scores: scores,
@@ -148,6 +156,98 @@ module ActionAgent
148
156
  end
149
157
  end
150
158
 
159
+ # --- Cohort summaries ------------------------------------------------------
160
+ #
161
+ # Stored under scores["_cohorts"], keyed by model: how many generations
162
+ # were sampled under it, how many cleared every criterion, their latency
163
+ # and token usage, and what those generations cost to serve. A comparison
164
+ # run gets one entry per requested cohort that had generations; a plain
165
+ # run one per model that happened to be among the sampled generations.
166
+ # This is what lets the dashboard show a run as "5/12 passed" per model,
167
+ # the way a scenario run's `_models` summaries do, without re-reading the
168
+ # generations.
169
+ #
170
+ # The cost here is the agent's: what the sampled interactions cost to
171
+ # operate. It is not what this run spent — scoring recorded generations
172
+ # costs nothing until a judge is asked, and the judge's own spend is
173
+ # recorded apart, under "_judge_usage" (see #judge_usage).
174
+
175
+ def cohort_summaries(samples, per_sample_scores)
176
+ samples
177
+ .group_by { |generation| generation.model.presence || "unknown" }
178
+ .transform_values { |group| cohort_summary(group, per_sample_scores) }
179
+ end
180
+
181
+ def cohort_summary(samples, per_sample_scores)
182
+ durations_ms = samples.filter_map do |generation|
183
+ seconds = generation.duration_seconds.to_f
184
+ seconds * 1000 if seconds.positive?
185
+ end
186
+ providers = samples.filter_map { |generation| generation.provider.presence }
187
+ input_tokens = samples.sum { |generation| generation.input_tokens.to_i }
188
+ output_tokens = samples.sum { |generation| generation.output_tokens.to_i }
189
+ costs = samples.filter_map do |generation|
190
+ ModelPricing.estimate(model: generation.model, input_tokens: generation.input_tokens, output_tokens: generation.output_tokens)
191
+ end
192
+
193
+ {
194
+ "samples" => samples.size,
195
+ "passed" => passed_count(per_sample_scores.slice(*samples.map(&:id))),
196
+ "provider" => providers.tally.max_by { |_provider, count| count }&.first,
197
+ "avg_duration_ms" => durations_ms.any? ? (durations_ms.sum / durations_ms.size).round : nil,
198
+ "input_tokens" => input_tokens,
199
+ "output_tokens" => output_tokens,
200
+ "cost" => costs.any? ? costs.sum.round(6) : nil
201
+ }
202
+ end
203
+
204
+ # --- Judge usage -----------------------------------------------------------
205
+ #
206
+ # Every call the judge makes is metered here, apart from the agent's own
207
+ # spend: scoring answers, recommending fixes, writing the verdict and,
208
+ # for a judge_defined evaluation, authoring the KPIs. The agent's cost is
209
+ # the operating figure — what serving these interactions costs — while
210
+ # the judge's is the evaluation's own, offline, agent-to-agent overhead,
211
+ # and a run that reported the two as one number would overstate the
212
+ # first. Persisted as scores["_judge_usage"]:
213
+ #
214
+ # { "calls", "input_tokens", "output_tokens", "cost", "model",
215
+ # "by_kind" => { "score" => n, "recommend" => n, "verdict" => n, "define" => n } }
216
+ #
217
+ # nil until the judge has been asked something, so a rules-only run
218
+ # records no judge at all rather than a judge that cost nothing.
219
+
220
+ def judge_usage
221
+ return nil if @judge_usage.nil?
222
+
223
+ @judge_usage.merge("cost" => @judge_usage["cost"]&.round(6))
224
+ end
225
+
226
+ # Asks the judge and meters the answer. `kind` is the call's purpose —
227
+ # :score, :recommend, :verdict or :define — the same vocabulary
228
+ # ActiveAgent::Evals::Judge hands a block that accepts `kind:`.
229
+ def judge_generate(kind, message:, instructions:)
230
+ response = judge_class.prompt(message: message, instructions: instructions).generate_now
231
+ record_judge_call(kind, response)
232
+ response
233
+ end
234
+
235
+ def record_judge_call(kind, response)
236
+ usage = response.respond_to?(:usage) ? response.usage : nil
237
+ input_tokens = usage.respond_to?(:input_tokens) ? usage.input_tokens.to_i : 0
238
+ output_tokens = usage.respond_to?(:output_tokens) ? usage.output_tokens.to_i : 0
239
+ model = (response.respond_to?(:model) && response.model.presence) || @evaluation.judge_model.presence
240
+ cost = ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
241
+
242
+ @judge_usage ||= { "calls" => 0, "input_tokens" => 0, "output_tokens" => 0, "cost" => nil, "model" => nil, "by_kind" => {} }
243
+ @judge_usage["calls"] += 1
244
+ @judge_usage["input_tokens"] += input_tokens
245
+ @judge_usage["output_tokens"] += output_tokens
246
+ @judge_usage["cost"] = (@judge_usage["cost"] || 0.0) + cost if cost
247
+ @judge_usage["model"] ||= model
248
+ @judge_usage["by_kind"][kind.to_s] = @judge_usage["by_kind"].fetch(kind.to_s, 0) + 1
249
+ end
250
+
151
251
  def sample_generations(model: nil)
152
252
  scope = @evaluation.agent.generations
153
253
  scope = scope.where(model: model) if model
@@ -175,7 +275,7 @@ module ActionAgent
175
275
  if total.zero?
176
276
  return {
177
277
  "skipped" => true,
178
- "reason" => "No telemetry traces for #{@evaluation.agent.telemetry_agent_class} in the last #{window_hours}h"
278
+ "reason" => "No telemetry traces for #{telemetry_source} in the last #{window_hours}h"
179
279
  }
180
280
  end
181
281
 
@@ -213,12 +313,18 @@ module ActionAgent
213
313
  end
214
314
 
215
315
  def telemetry_traces(window_hours)
216
- ActionAgent.trace_model
217
- .for_account(ActionAgent.tenant_for(owner))
218
- .for_agent(@evaluation.agent.telemetry_agent_class)
316
+ @evaluation.agent
317
+ .telemetry_traces(ActionAgent.trace_model.for_account(ActionAgent.tenant_for(owner)))
219
318
  .for_date_range(window_hours.hours.ago, Time.current)
220
319
  end
221
320
 
321
+ # What a skip reason says was looked for: the observed agent itself, or
322
+ # the class any other agent's traces are reported under.
323
+ def telemetry_source
324
+ agent = @evaluation.agent
325
+ agent.observed? ? agent.name : agent.telemetry_agent_class
326
+ end
327
+
222
328
  # Returns 0.0..1.0, or nil when the criterion cannot be scored.
223
329
  def score_sample(criterion, generation)
224
330
  config = criterion["config"] || {}
@@ -273,10 +379,11 @@ module ActionAgent
273
379
  raise "Judge-defined KPIs need provider credentials (add a provider API key in Settings)"
274
380
  end
275
381
 
276
- response = judge_class.prompt(
382
+ response = judge_generate(
383
+ :define,
277
384
  message: kpi_definition_prompt,
278
385
  instructions: "You define measurable evaluation KPIs for AI agents. Respond ONLY with JSON."
279
- ).generate_now
386
+ )
280
387
 
281
388
  kpis = parse_kpis(response.message&.content)
282
389
  raise "Judge returned no usable KPIs — try again or add criteria manually" if kpis.empty?
@@ -354,7 +461,8 @@ module ActionAgent
354
461
  "#{key}: #{cells.join(', ')}"
355
462
  end
356
463
 
357
- response = judge_class.prompt(
464
+ response = judge_generate(
465
+ :verdict,
358
466
  message: <<~PROMPT,
359
467
  An AI agent was evaluated under multiple models. Its goals:
360
468
  ---
@@ -368,7 +476,7 @@ module ActionAgent
368
476
  Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
369
477
  PROMPT
370
478
  instructions: "You are an impartial evaluation judge comparing model cohorts. Respond ONLY with JSON."
371
- ).generate_now
479
+ )
372
480
 
373
481
  json = response.message&.content.to_s[/\{.*\}/m]
374
482
  verdict = json ? JSON.parse(json) : nil
@@ -390,10 +498,11 @@ module ActionAgent
390
498
  return nil unless judge_available?
391
499
  return nil if generation.content.blank?
392
500
 
393
- response = judge_class.prompt(
501
+ response = judge_generate(
502
+ :score,
394
503
  message: judge_prompt(criterion, generation),
395
504
  instructions: "You are an impartial evaluation judge. Respond ONLY with JSON: {\"score\": <float between 0.0 and 1.0>}"
396
- ).generate_now
505
+ )
397
506
 
398
507
  parse_judge_score(response.message&.content)
399
508
  rescue StandardError => e
@@ -261,6 +261,10 @@ module ActionAgent
261
261
  scores["_selection"] = run.selection
262
262
  scores["_metadata"] = report.metadata
263
263
  scores["_judge_label"] = report.judge_label || report.judge&.label
264
+ # The judge's own spend, apart from the replays' (which the results
265
+ # carry and EvaluationRun#usage sums). A host adapter that runs its own
266
+ # judge is out of reach of this meter, so its runs record none.
267
+ scores["_judge_usage"] = judge_usage if judge_usage
264
268
  scores
265
269
  end
266
270
 
@@ -318,12 +322,13 @@ module ActionAgent
318
322
 
319
323
  # The judge the evaluation's owner has credentials for, wrapped for the
320
324
  # evaluation core; nil when none is configured, in which case scoring
321
- # stays on rules and expectations.
325
+ # stays on rules and expectations. The block takes `kind:` so each call
326
+ # is metered under what it was for (EvaluationRunnerService#judge_generate).
322
327
  def evals_judge
323
328
  return nil unless judge_available?
324
329
 
325
- @evals_judge ||= Evals::Judge.new(label: @evaluation.judge_model.presence || judge_provider.to_s) do |instructions:, prompt:|
326
- judge_class.prompt(message: prompt, instructions: instructions).generate_now.message&.content
330
+ @evals_judge ||= Evals::Judge.new(label: @evaluation.judge_model.presence || judge_provider.to_s) do |instructions:, prompt:, kind:|
331
+ judge_generate(kind, message: prompt, instructions: instructions).message&.content
327
332
  end
328
333
  end
329
334
  end
data/config/routes.rb CHANGED
@@ -27,6 +27,11 @@ ActionAgent::Engine.routes.draw do
27
27
  # Authenticated with a bearer token, not a session.
28
28
  resources :traces, only: [ :create ]
29
29
 
30
+ # The collector for evaluation reports an application ran itself
31
+ # (ActiveAgent::Evals::Publisher), at <mount>/api/evaluation_reports.
32
+ # Authenticated like trace ingest, with a bearer token.
33
+ resources :evaluation_reports, only: [ :create ]
34
+
30
35
  # A JSON API has no :new or :edit forms to serve.
31
36
  resources :agents, except: [ :new, :edit ] do
32
37
  member do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.6.4"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/action_agent.rb CHANGED
@@ -209,24 +209,77 @@ module ActionAgent
209
209
  # @return [Object, nil] Object responding to #signed_url_for and #fetch_snapshot
210
210
  attr_accessor :storage_service
211
211
 
212
- # Bearer token required by the ingest API in single-tenant mode. When
213
- # unset the local ingest endpoint accepts unauthenticated posts, so set
214
- # it whenever the mount is reachable beyond your own machine.
215
- # (Multi-tenant mode authenticates per-account keys instead.)
212
+ # Bearer token required in single-tenant mode by the endpoints other
213
+ # applications post to: trace ingest (<mount>/api/traces) and published
214
+ # evaluation reports (<mount>/api/evaluation_reports). When unset both
215
+ # accept unauthenticated posts, so set it whenever the mount is reachable
216
+ # beyond your own machine. Trace ingest also takes a form post, which any
217
+ # web page open in a browser on that machine can send it; the report
218
+ # collector takes only application/json, which a page cannot send
219
+ # cross-site. (Multi-tenant mode authenticates per-account keys instead.)
216
220
  # @return [String, nil]
217
221
  attr_accessor :ingest_api_key
218
222
 
223
+ # Concerns included into ActionAgent::ApplicationRecord as it loads, and
224
+ # through it into every engine model. An entry is a Module or the name
225
+ # of one. A name is resolved when the class
226
+ # loads, so an initializer can refer to a constant the host has not
227
+ # autoloaded yet, and a name that resolves to nothing raises NameError
228
+ # there rather than being skipped.
229
+ #
230
+ # ActionAgent.configure do |config|
231
+ # config.model_concerns = ["MyApp::ConnectionSwitching"]
232
+ # end
233
+ #
234
+ # The class loads after the initializers have run, so set this in an
235
+ # initializer; a concern added later is not applied.
236
+ # @return [Array<Module, String>]
237
+ attr_accessor :model_concerns
238
+
239
+ # Concerns included into ActionAgent::ApplicationController as it loads,
240
+ # and through it into every dashboard controller: the React dashboard
241
+ # and its JSON API, the server-rendered console and the MCP facade.
242
+ # They are included ahead of the engine's own callbacks, so a concern's
243
+ # before_action or around_action runs before the dashboard
244
+ # authenticates. Entries are Modules or names, as for model_concerns.
245
+ #
246
+ # Not the endpoints other applications post to: Api::TracesController
247
+ # and Api::EvaluationReportsController authenticate with a bearer token
248
+ # and inherit ActionController::API.
249
+ #
250
+ # ActionAgent.configure do |config|
251
+ # config.controller_concerns = ["MyApp::RequestTagging"]
252
+ # end
253
+ # @return [Array<Module, String>]
254
+ attr_accessor :controller_concerns
255
+
219
256
  # @deprecated Never consumed — dashboard controllers inherit
220
- # ActionController::Base. Retained as a no-op so existing
221
- # initializers that set it keep booting; remove in the next major.
257
+ # ActionController::Base, and controller_concerns is how a host puts
258
+ # its own behaviour on them. Assigning it warns and stores a value
259
+ # nothing reads; removed in the next major.
222
260
  # @return [String]
223
- attr_accessor :base_controller_class
261
+ attr_reader :base_controller_class
262
+
263
+ def base_controller_class=(value)
264
+ deprecator.warn(
265
+ "ActionAgent.base_controller_class has never been consumed and is removed in 2.0. " \
266
+ "Set ActionAgent.controller_concerns to extend the dashboard's controllers."
267
+ )
268
+ @base_controller_class = value
269
+ end
224
270
 
225
- # Called before each run/trace-ingest to enforce host-app limits.
226
- # Receives (owner, kind) where kind is :execution or :trace_ingest, and
227
- # returns nil to allow, or to deny: a message String, or a Hash merged
228
- # into the response so the app can surface its own usage numbers.
229
- # Denials surface as HTTP 402 (execution) / 429 (ingest).
271
+ # Called before each metered action to enforce host-app limits.
272
+ # Receives (owner, kind) and returns nil to allow, or to deny: a message
273
+ # String, or a Hash merged into the response so the app can surface its
274
+ # own usage numbers. The kinds, and how a denial surfaces:
275
+ #
276
+ # :execution — an agent run; HTTP 402
277
+ # :trace_ingest — a POST to <mount>/api/traces; HTTP 429
278
+ # :evaluation_report — a report <mount>/api/evaluation_reports would
279
+ # store (never an identical retry); HTTP 429
280
+ #
281
+ # The owner of an ingest kind is the tenant the key resolved to, nil on a
282
+ # single-tenant install.
230
283
  #
231
284
  # Unset means unlimited, which is what a self-hosted install wants.
232
285
  # @return [Proc, nil]
@@ -256,6 +309,21 @@ module ActionAgent
256
309
  # @return [Boolean]
257
310
  attr_accessor :execution_enabled
258
311
 
312
+ # Whether a run of an agent that mirrors a host class executes that class,
313
+ # instead of the class the engine builds from the record's `tools` and
314
+ # `instructions` columns.
315
+ #
316
+ # Off by default: it changes what a run of a mirrored agent executes, and
317
+ # a host that has tuned its dashboard records around the dynamic runtime
318
+ # should opt in deliberately. Dashboard-authored agents — the ones with no
319
+ # `agent_class_name` — are unaffected either way.
320
+ #
321
+ # On, a mirrored agent runs its real tools, delegations and instructions,
322
+ # so an evaluation scores the agent production runs rather than a
323
+ # flattened copy of it.
324
+ # @return [Boolean]
325
+ attr_accessor :run_host_agent_classes
326
+
259
327
  # Whether the "Ask ActiveAgents" assistant is available.
260
328
  #
261
329
  # The assistant is a tool for developing and CI-ing agents: it sends
@@ -314,7 +382,9 @@ module ActionAgent
314
382
 
315
383
  # Called after the dashboard performs a metered action, as
316
384
  # (owner, kind) — the counterpart to quota_checker, for host apps that
317
- # track usage against a plan. Unset means nothing is counted.
385
+ # track usage against a plan. The kinds are :execution, for each agent
386
+ # run, and :evaluation_report, for each report the collector stores; an
387
+ # identical retry is not counted again. Unset means nothing is counted.
318
388
  # @return [Proc, nil]
319
389
  attr_accessor :usage_recorder
320
390
 
@@ -323,6 +393,11 @@ module ActionAgent
323
393
  # nobody in single-tenant mode. A host app whose agents hang off a
324
394
  # different record (the platform's hang off the account's owning user)
325
395
  # supplies its own mapping.
396
+ #
397
+ # A published evaluation report's agent is placed the same way: the
398
+ # resolver receives an unsaved trace with the publishing tenant as its
399
+ # account, and the report's source and agent name as its service_name and
400
+ # agent_class. In multi-tenant mode it must not return nil there.
326
401
  # @return [Proc, nil]
327
402
  attr_accessor :trace_owner_resolver
328
403
 
@@ -513,6 +588,19 @@ module ActionAgent
513
588
  name&.safe_constantize
514
589
  end
515
590
 
591
+ # The modules model_concerns names. ApplicationRecord reads it as it loads.
592
+ # @return [Array<Module>]
593
+ def model_concern_modules
594
+ resolve_concerns(model_concerns)
595
+ end
596
+
597
+ # The modules controller_concerns names. ApplicationController reads it
598
+ # as it loads.
599
+ # @return [Array<Module>]
600
+ def controller_concern_modules
601
+ resolve_concerns(controller_concerns)
602
+ end
603
+
516
604
  # Configures the dashboard.
517
605
  #
518
606
  # @yield [config] Configuration block
@@ -540,10 +628,13 @@ module ActionAgent
540
628
  @storage_service = nil
541
629
  @ingest_api_key = nil
542
630
  @base_controller_class = "ActionController::Base" # deprecated no-op
631
+ @model_concerns = []
632
+ @controller_concerns = []
543
633
  @quota_checker = nil
544
634
  @provider_credentials_resolver = nil
545
635
  @sandbox_backends = {}
546
636
  @execution_enabled = true
637
+ @run_host_agent_classes = false
547
638
  @assistant_enabled = nil
548
639
 
549
640
  @scenario_evaluation_adapter_resolver = nil
@@ -639,6 +730,12 @@ module ActionAgent
639
730
  def schema_tool_class_for(name)
640
731
  schema_tool_classes.find { |klass| klass.tool?(name) }
641
732
  end
733
+
734
+ private
735
+
736
+ def resolve_concerns(entries)
737
+ Array(entries).map { |entry| entry.is_a?(Module) ? entry : entry.to_s.constantize }
738
+ end
642
739
  end
643
740
 
644
741
  # Set defaults
@@ -77,13 +77,36 @@ module ActionAgent
77
77
  )
78
78
  end
79
79
 
80
+ # add_agent_releases is emitted before the dashboard tables, so on an
81
+ # install generated fresh before the create-table migration carried the
82
+ # release columns it skipped every table, and its copy may name tables
83
+ # without the configured prefix. This adds whatever is still missing,
84
+ # and changes nothing where the columns exist.
85
+ unless existing_migration?("ensure_agent_release_columns")
86
+ migration_template(
87
+ "ensure_agent_release_columns.rb.erb",
88
+ "db/migrate/ensure_agent_release_columns.rb"
89
+ )
90
+ end
91
+
80
92
  # Scenario suites arrived after the dashboard tables shipped, so an
81
93
  # install that already has those still needs this one.
82
- return if existing_migration?("create_active_agent_evaluation_scenarios")
94
+ unless existing_migration?("create_active_agent_evaluation_scenarios")
95
+ migration_template(
96
+ "create_active_agent_evaluation_scenarios.rb.erb",
97
+ "db/migrate/create_active_agent_evaluation_scenarios.rb"
98
+ )
99
+ end
100
+
101
+ # Published evaluation reports arrived after the dashboard tables
102
+ # shipped. Emitted after them, so on a fresh install it runs once the
103
+ # table exists, finds the columns the create-table migration made, and
104
+ # changes nothing.
105
+ return if existing_migration?("add_evaluation_report_identity")
83
106
 
84
107
  migration_template(
85
- "create_active_agent_evaluation_scenarios.rb.erb",
86
- "db/migrate/create_active_agent_evaluation_scenarios.rb"
108
+ "add_evaluation_report_identity.rb.erb",
109
+ "db/migrate/add_evaluation_report_identity.rb"
87
110
  )
88
111
  end
89
112
 
@@ -44,9 +44,11 @@ ActionAgent.configure do |config|
44
44
  # Ingest API authentication
45
45
  # ==========================================================================
46
46
  #
47
- # Bearer token other apps must send when posting traces to the mounted
48
- # ingest endpoint (/activeagents/api/traces). Leave unset only when the
49
- # mount is not reachable beyond your own machine.
47
+ # Bearer token other apps must send when posting traces
48
+ # (/activeagents/api/traces) or evaluation reports
49
+ # (/activeagents/api/evaluation_reports) to the mount. Leave unset only
50
+ # when the mount is not reachable beyond your own machine; even then, any web
51
+ # page open in a browser there can post form data to the trace endpoint.
50
52
  #
51
53
  # config.ingest_api_key = Rails.application.credentials.dig(:active_agent, :ingest_api_key)
52
54
 
@@ -78,6 +80,20 @@ ActionAgent.configure do |config|
78
80
  # nothing rather than everything. An empty dashboard for a signed-in user
79
81
  # means the resolver above returned nil.
80
82
 
83
+ # ==========================================================================
84
+ # Host integration
85
+ # ==========================================================================
86
+ #
87
+ # Concerns your own models and controllers carry, applied to the engine's.
88
+ # model_concerns are included into ActionAgent::ApplicationRecord, and so
89
+ # into every engine model. controller_concerns are included into
90
+ # ActionAgent::ApplicationController ahead of its own callbacks, and so
91
+ # into every dashboard controller — not the bearer-token ingest endpoints.
92
+ # Modules or their names; a name is resolved when the class loads.
93
+ #
94
+ # config.model_concerns = ["ConnectionSwitching"]
95
+ # config.controller_concerns = ["RequestTagging"]
96
+
81
97
  # ==========================================================================
82
98
  # Ask ActiveAgents assistant
83
99
  # ==========================================================================
@@ -7,33 +7,38 @@
7
7
  # migration carries the same columns for a fresh install.
8
8
  #
9
9
  # Each step is guarded so the migration is safe to re-run against a database
10
- # that already has some of these columns.
10
+ # that already has some of these columns. Table names follow
11
+ # ActionAgent.table_name_prefix, except the trace table, whose name is fixed.
11
12
  class AddAgentReleases < ActiveRecord::Migration<%= migration_version %>
12
13
  def up
13
- add_column_unless_exists :active_agent_agents, :release_digest, :string
14
+ add_column_unless_exists table(:agents), :release_digest, :string
14
15
 
15
- add_column_unless_exists :active_agent_agent_versions, :release_digest, :string
16
- add_column_unless_exists :active_agent_agent_versions, :revision, :string
17
- add_index_unless_exists :active_agent_agent_versions, [ :agent_id, :release_digest ]
16
+ add_column_unless_exists table(:agent_versions), :release_digest, :string
17
+ add_column_unless_exists table(:agent_versions), :revision, :string
18
+ add_index_unless_exists table(:agent_versions), [ :agent_id, :release_digest ]
18
19
 
19
- %i[active_agent_telemetry_traces active_agent_agent_runs active_agent_evaluation_runs].each do |table|
20
- add_column_unless_exists table, :agent_version_id, :bigint
21
- add_index_unless_exists table, :agent_version_id
20
+ [ "active_agent_telemetry_traces", table(:agent_runs), table(:evaluation_runs) ].each do |name|
21
+ add_column_unless_exists name, :agent_version_id, :bigint
22
+ add_index_unless_exists name, :agent_version_id
22
23
  end
23
24
  end
24
25
 
25
26
  def down
26
- remove_column :active_agent_agents, :release_digest if column_exists?(:active_agent_agents, :release_digest)
27
- remove_column :active_agent_agent_versions, :release_digest if column_exists?(:active_agent_agent_versions, :release_digest)
28
- remove_column :active_agent_agent_versions, :revision if column_exists?(:active_agent_agent_versions, :revision)
27
+ remove_column table(:agents), :release_digest if column_exists?(table(:agents), :release_digest)
28
+ remove_column table(:agent_versions), :release_digest if column_exists?(table(:agent_versions), :release_digest)
29
+ remove_column table(:agent_versions), :revision if column_exists?(table(:agent_versions), :revision)
29
30
 
30
- %i[active_agent_telemetry_traces active_agent_agent_runs active_agent_evaluation_runs].each do |table|
31
- remove_column table, :agent_version_id if column_exists?(table, :agent_version_id)
31
+ [ "active_agent_telemetry_traces", table(:agent_runs), table(:evaluation_runs) ].each do |name|
32
+ remove_column name, :agent_version_id if column_exists?(name, :agent_version_id)
32
33
  end
33
34
  end
34
35
 
35
36
  private
36
37
 
38
+ def table(name)
39
+ "#{ActionAgent.table_name_prefix}#{name}"
40
+ end
41
+
37
42
  def add_column_unless_exists(table, column, type)
38
43
  return unless table_exists?(table)
39
44
  return if column_exists?(table, column)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Published evaluation reports: an application that runs its agents itself
4
+ # can evaluate them in-process and POST the finished report to
5
+ # <mount>/api/evaluation_reports (ActionAgent::EvaluationReportImport), which
6
+ # stores it as an evaluation run. The run is identified by the run_id the
7
+ # application minted, within the tenant whose key published it, and carries
8
+ # the digest of the report, so an identical retry resolves to the same run and
9
+ # different content under the same run_id is refused.
10
+ #
11
+ # The tenant is its id, or "" on a single-tenant install, because a unique
12
+ # index never treats two NULLs as equal. Both are compared exactly, which on
13
+ # MySQL takes a binary collation. All three columns are NULL for a run the
14
+ # dashboard executed. Emitted for an install whose dashboard tables predate
15
+ # published reports; the create-table migration carries the same columns for
16
+ # a fresh install. Each step is guarded, so the migration is safe to re-run.
17
+ class AddEvaluationReportIdentity < ActiveRecord::Migration<%= migration_version %>
18
+ IDENTITY = [ :external_tenant, :external_run_id ].freeze
19
+
20
+ def up
21
+ return unless table_exists?(table)
22
+
23
+ IDENTITY.each do |column|
24
+ add_column table, column, :string, **exact_collation unless column_exists?(table, column)
25
+ end
26
+ add_column table, :external_report_digest, :string unless column_exists?(table, :external_report_digest)
27
+ add_index table, IDENTITY, unique: true, name: index_name unless index_exists?(table, IDENTITY, name: index_name)
28
+ end
29
+
30
+ def down
31
+ return unless table_exists?(table)
32
+
33
+ remove_index table, name: index_name if index_exists?(table, IDENTITY, name: index_name)
34
+ %i[external_tenant external_run_id external_report_digest].each do |column|
35
+ remove_column table, column if column_exists?(table, column)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def table
42
+ "#{ActionAgent.table_name_prefix}evaluation_runs"
43
+ end
44
+
45
+ def index_name
46
+ "index_#{ActionAgent.table_name_prefix}evaluation_runs_on_external_identity"
47
+ end
48
+
49
+ # MySQL's default collation ignores case and accents, so "nightly-a" and
50
+ # "Nightly-A" would be one run_id there and two everywhere else.
51
+ def exact_collation
52
+ connection.adapter_name.to_s.downcase.match?(/mysql|trilogy/) ? { collation: "utf8mb4_bin" } : {}
53
+ end
54
+ end