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
|
@@ -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 #{
|
|
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
|
-
|
|
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 =
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
)
|
|
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 =
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
data/lib/action_agent/version.rb
CHANGED
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
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
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
|
|
221
|
-
#
|
|
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
|
-
|
|
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
|
|
226
|
-
# Receives (owner, kind)
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
86
|
-
"db/migrate/
|
|
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
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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 :
|
|
14
|
+
add_column_unless_exists table(:agents), :release_digest, :string
|
|
14
15
|
|
|
15
|
-
add_column_unless_exists :
|
|
16
|
-
add_column_unless_exists :
|
|
17
|
-
add_index_unless_exists :
|
|
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
|
-
|
|
20
|
-
add_column_unless_exists
|
|
21
|
-
add_index_unless_exists
|
|
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 :
|
|
27
|
-
remove_column :
|
|
28
|
-
remove_column :
|
|
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
|
-
|
|
31
|
-
remove_column
|
|
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
|