actionagent 0.0.0 → 1.2.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/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- metadata +209 -12
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Runs an Evaluation against the agent's most recent persisted generations
|
|
5
|
+
# (solid_agent's agent_generations).
|
|
6
|
+
#
|
|
7
|
+
# Rule-based criteria are scored deterministically from the recorded data.
|
|
8
|
+
# The llm_judge criterion asks a judge model (through the activeagent gem)
|
|
9
|
+
# to score each sample 0.0..1.0; it requires configured provider
|
|
10
|
+
# credentials and is skipped — never faked — when none are available.
|
|
11
|
+
class EvaluationRunnerService
|
|
12
|
+
PASS_THRESHOLD = 0.7
|
|
13
|
+
|
|
14
|
+
def self.call(evaluation)
|
|
15
|
+
new(evaluation).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(evaluation)
|
|
19
|
+
@evaluation = evaluation
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
run = @evaluation.evaluation_runs.create!(status: :running)
|
|
24
|
+
|
|
25
|
+
# judge_defined evaluations author their KPI criteria on first run.
|
|
26
|
+
ensure_judge_defined_kpis! if @evaluation.judge_defined?
|
|
27
|
+
|
|
28
|
+
sample_criteria, telemetry_criteria = @evaluation.criteria.partition do |criterion|
|
|
29
|
+
!Evaluation::TELEMETRY_CRITERION_TYPES.include?(criterion["type"])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if @evaluation.compare_models.any?
|
|
33
|
+
return score_comparison(run, sample_criteria, telemetry_criteria)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
samples = sample_criteria.any? ? sample_generations : []
|
|
37
|
+
if sample_criteria.any? && samples.empty?
|
|
38
|
+
run.update!(
|
|
39
|
+
status: :failed,
|
|
40
|
+
error_message: "No generations to evaluate yet — run the agent first",
|
|
41
|
+
completed_at: Time.current
|
|
42
|
+
)
|
|
43
|
+
return run
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
scores = {}
|
|
47
|
+
per_sample_scores = Hash.new { |h, k| h[k] = [] }
|
|
48
|
+
|
|
49
|
+
telemetry_criteria.each do |criterion|
|
|
50
|
+
scores[criterion["key"]] = score_telemetry_criterion(criterion)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sample_criteria.each do |criterion|
|
|
54
|
+
stats = criterion_stats(criterion, samples, per_sample_scores)
|
|
55
|
+
scores[criterion["key"]] = stats
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
run.update!(
|
|
59
|
+
status: :complete,
|
|
60
|
+
scores: scores,
|
|
61
|
+
samples_evaluated: samples.size,
|
|
62
|
+
samples_passed: passed_count(per_sample_scores),
|
|
63
|
+
completed_at: Time.current
|
|
64
|
+
)
|
|
65
|
+
run
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
run&.update!(status: :failed, error_message: e.message, completed_at: Time.current)
|
|
68
|
+
raise
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Scores each sample-based criterion once per candidate model cohort and
|
|
74
|
+
# asks the judge for a comparative verdict — "haiku vs qwen, judged".
|
|
75
|
+
def score_comparison(run, sample_criteria, telemetry_criteria)
|
|
76
|
+
cohorts = @evaluation.compare_models.index_with { |model| sample_generations(model: model) }
|
|
77
|
+
active = cohorts.select { |_model, samples| samples.any? }
|
|
78
|
+
|
|
79
|
+
if active.empty?
|
|
80
|
+
run.update!(
|
|
81
|
+
status: :failed,
|
|
82
|
+
error_message: "No generations recorded under #{@evaluation.compare_models.join(', ')} — run the agent under those models first",
|
|
83
|
+
completed_at: Time.current
|
|
84
|
+
)
|
|
85
|
+
return run
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
scores = {}
|
|
89
|
+
per_model_sample_scores = {}
|
|
90
|
+
|
|
91
|
+
telemetry_criteria.each do |criterion|
|
|
92
|
+
scores[criterion["key"]] = score_telemetry_criterion(criterion)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sample_criteria.each do |criterion|
|
|
96
|
+
scores[criterion["key"]] = active.each_with_object({}) do |(model, samples), by_model|
|
|
97
|
+
per_sample = (per_model_sample_scores[model] ||= Hash.new { |h, k| h[k] = [] })
|
|
98
|
+
by_model[model] = criterion_stats(criterion, samples, per_sample)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Models that were requested but have no recorded generations are
|
|
103
|
+
# reported, not silently dropped.
|
|
104
|
+
missing = cohorts.keys - active.keys
|
|
105
|
+
scores["_missing_models"] = missing if missing.any?
|
|
106
|
+
|
|
107
|
+
if active.size >= 2 && sample_criteria.any?
|
|
108
|
+
verdict = comparison_verdict(active, sample_criteria, scores)
|
|
109
|
+
scores["_verdict"] = verdict if verdict
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
run.update!(
|
|
113
|
+
status: :complete,
|
|
114
|
+
scores: scores,
|
|
115
|
+
samples_evaluated: active.values.sum(&:size),
|
|
116
|
+
samples_passed: per_model_sample_scores.values.sum { |per_sample| passed_count(per_sample) },
|
|
117
|
+
completed_at: Time.current
|
|
118
|
+
)
|
|
119
|
+
run
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Shared per-criterion scoring: returns the stats hash (or skipped) and
|
|
123
|
+
# appends each sample's score into per_sample_scores for pass counting.
|
|
124
|
+
def criterion_stats(criterion, samples, per_sample_scores)
|
|
125
|
+
sample_scores = samples.map { |generation| score_sample(criterion, generation) }
|
|
126
|
+
|
|
127
|
+
# nil means the criterion could not be scored (e.g. llm_judge without
|
|
128
|
+
# provider credentials); it is reported as skipped, not zero.
|
|
129
|
+
scored = sample_scores.compact
|
|
130
|
+
return { "skipped" => true, "reason" => skip_reason(criterion) } if scored.empty?
|
|
131
|
+
|
|
132
|
+
samples.each_with_index do |generation, index|
|
|
133
|
+
per_sample_scores[generation.id] << sample_scores[index] if sample_scores[index]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
"score" => (scored.sum / scored.size).round(3),
|
|
138
|
+
"min" => scored.min.round(3),
|
|
139
|
+
"max" => scored.max.round(3),
|
|
140
|
+
"passed" => scored.count { |s| s >= PASS_THRESHOLD },
|
|
141
|
+
"total" => scored.size
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def passed_count(per_sample_scores)
|
|
146
|
+
per_sample_scores.count do |_id, values|
|
|
147
|
+
values.any? && values.all? { |s| s >= PASS_THRESHOLD }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sample_generations(model: nil)
|
|
152
|
+
scope = AgentGeneration
|
|
153
|
+
.joins(:agent_context)
|
|
154
|
+
.where(AgentContext.table_name => { contextable: @evaluation.agent })
|
|
155
|
+
scope = scope.where(model: model) if model
|
|
156
|
+
scope.order(created_at: :desc).limit(@evaluation.sample_size).to_a
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- Telemetry criteria ---------------------------------------------------
|
|
160
|
+
#
|
|
161
|
+
# Scored from the agent's telemetry traces over a config window — an
|
|
162
|
+
# aggregate per criterion, not per sample. min/max/passed/total mirror the
|
|
163
|
+
# aggregate so results render like sample-based criteria in the UI.
|
|
164
|
+
|
|
165
|
+
def score_telemetry_criterion(criterion)
|
|
166
|
+
config = criterion["config"] || {}
|
|
167
|
+
window_hours = config.fetch("window_hours", 168).to_i.clamp(1, 720)
|
|
168
|
+
|
|
169
|
+
# Multi-tenant installs need an owner to scope traces to; a
|
|
170
|
+
# single-tenant one reads every trace it has.
|
|
171
|
+
if ActionAgent.multi_tenant? && owner.nil?
|
|
172
|
+
return { "skipped" => true, "reason" => "No tenant for telemetry lookup" }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
traces = telemetry_traces(window_hours)
|
|
176
|
+
total = traces.count
|
|
177
|
+
if total.zero?
|
|
178
|
+
return {
|
|
179
|
+
"skipped" => true,
|
|
180
|
+
"reason" => "No telemetry traces for #{@evaluation.agent.telemetry_agent_class} in the last #{window_hours}h"
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
score, observed = case criterion["type"]
|
|
185
|
+
when "trace_error_rate"
|
|
186
|
+
max_rate = config.fetch("max_error_rate", 5.0).to_f
|
|
187
|
+
errors = traces.with_errors.count
|
|
188
|
+
rate = errors * 100.0 / total
|
|
189
|
+
value = if rate <= max_rate
|
|
190
|
+
1.0
|
|
191
|
+
elsif rate.zero?
|
|
192
|
+
1.0
|
|
193
|
+
else
|
|
194
|
+
max_rate.positive? ? (max_rate / rate).clamp(0.0, 1.0) : 0.0
|
|
195
|
+
end
|
|
196
|
+
[ value, { "error_rate" => rate.round(2), "errors" => errors, "max_error_rate" => max_rate } ]
|
|
197
|
+
when "trace_latency"
|
|
198
|
+
budget = config.fetch("max_avg_ms", 5_000).to_f
|
|
199
|
+
avg = traces.average(:total_duration_ms).to_f
|
|
200
|
+
value = avg.zero? || avg <= budget ? 1.0 : (budget / avg).clamp(0.0, 1.0)
|
|
201
|
+
[ value, { "avg_duration_ms" => avg.round, "max_avg_ms" => budget } ]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
"score" => score.round(3),
|
|
206
|
+
"min" => score.round(3),
|
|
207
|
+
"max" => score.round(3),
|
|
208
|
+
"passed" => score >= PASS_THRESHOLD ? 1 : 0,
|
|
209
|
+
"total" => 1,
|
|
210
|
+
"source" => "telemetry",
|
|
211
|
+
"window_hours" => window_hours,
|
|
212
|
+
"traces" => total,
|
|
213
|
+
"observed" => observed
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def telemetry_traces(window_hours)
|
|
218
|
+
ActionAgent.trace_model
|
|
219
|
+
.for_account(ActionAgent.tenant_for(owner))
|
|
220
|
+
.for_agent(@evaluation.agent.telemetry_agent_class)
|
|
221
|
+
.for_date_range(window_hours.hours.ago, Time.current)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns 0.0..1.0, or nil when the criterion cannot be scored.
|
|
225
|
+
def score_sample(criterion, generation)
|
|
226
|
+
config = criterion["config"] || {}
|
|
227
|
+
|
|
228
|
+
case criterion["type"]
|
|
229
|
+
when "response_present"
|
|
230
|
+
generation.content.present? ? 1.0 : 0.0
|
|
231
|
+
when "min_length"
|
|
232
|
+
min = config.fetch("chars", 40).to_i
|
|
233
|
+
length = generation.content.to_s.length
|
|
234
|
+
[ length.to_f / min, 1.0 ].min
|
|
235
|
+
when "max_latency_ms"
|
|
236
|
+
budget = config.fetch("ms", 5_000).to_f
|
|
237
|
+
duration_ms = generation.duration_seconds.to_f * 1000
|
|
238
|
+
return 1.0 if duration_ms.zero? # duration not recorded
|
|
239
|
+
duration_ms <= budget ? 1.0 : [ budget / duration_ms, 1.0 ].min
|
|
240
|
+
when "token_budget"
|
|
241
|
+
budget = config.fetch("output_tokens", 1_000).to_f
|
|
242
|
+
tokens = generation.output_tokens.to_f
|
|
243
|
+
tokens <= budget ? 1.0 : [ budget / tokens, 1.0 ].min
|
|
244
|
+
when "contains"
|
|
245
|
+
matches_pattern?(generation.content, config) ? 1.0 : 0.0
|
|
246
|
+
when "not_contains"
|
|
247
|
+
matches_pattern?(generation.content, config) ? 0.0 : 1.0
|
|
248
|
+
when "llm_judge"
|
|
249
|
+
llm_judge_score(criterion, generation)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def matches_pattern?(content, config)
|
|
254
|
+
pattern = config["pattern"].to_s
|
|
255
|
+
return false if pattern.blank?
|
|
256
|
+
|
|
257
|
+
content.to_s.match?(Regexp.new(pattern, Regexp::IGNORECASE))
|
|
258
|
+
rescue RegexpError
|
|
259
|
+
content.to_s.downcase.include?(pattern.downcase)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# --- Judge-defined KPIs --------------------------------------------------
|
|
263
|
+
#
|
|
264
|
+
# The judge reads the agent's goals (its instructions) plus sample
|
|
265
|
+
# interactions and authors 3-6 measurable KPIs, persisted as llm_judge
|
|
266
|
+
# criteria with provenance. Stable persisted KPIs are what make scores
|
|
267
|
+
# comparable across evaluation runs and across models.
|
|
268
|
+
|
|
269
|
+
KPI_LIMIT = 6
|
|
270
|
+
|
|
271
|
+
def ensure_judge_defined_kpis!
|
|
272
|
+
return if @evaluation.criteria.any? { |c| c["type"] == "llm_judge" && c["defined_by"].present? }
|
|
273
|
+
|
|
274
|
+
unless judge_available?
|
|
275
|
+
raise "Judge-defined KPIs need provider credentials (add a provider API key in Settings)"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
response = judge_class.prompt(
|
|
279
|
+
message: kpi_definition_prompt,
|
|
280
|
+
instructions: "You define measurable evaluation KPIs for AI agents. Respond ONLY with JSON."
|
|
281
|
+
).generate_now
|
|
282
|
+
|
|
283
|
+
kpis = parse_kpis(response.message&.content)
|
|
284
|
+
raise "Judge returned no usable KPIs — try again or add criteria manually" if kpis.empty?
|
|
285
|
+
|
|
286
|
+
judge_label = @evaluation.judge_model.presence || judge_provider.to_s
|
|
287
|
+
defined_at = Time.current.iso8601
|
|
288
|
+
kpi_criteria = kpis.first(KPI_LIMIT).map do |kpi|
|
|
289
|
+
prompt_text = [
|
|
290
|
+
kpi["description"].to_s,
|
|
291
|
+
kpi["scoring_guidance"].presence && "Scoring guidance: #{kpi['scoring_guidance']}"
|
|
292
|
+
].compact.join("\n")
|
|
293
|
+
{
|
|
294
|
+
"key" => kpi["key"].to_s.parameterize(separator: "_").presence || kpi["description"].to_s.parameterize(separator: "_").first(40),
|
|
295
|
+
"type" => "llm_judge",
|
|
296
|
+
"defined_by" => judge_label,
|
|
297
|
+
"defined_at" => defined_at,
|
|
298
|
+
"config" => { "prompt" => prompt_text, "description" => kpi["description"] }
|
|
299
|
+
}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
@evaluation.update!(
|
|
303
|
+
criteria: @evaluation.criteria + kpi_criteria,
|
|
304
|
+
config: @evaluation.config.merge(
|
|
305
|
+
"kpi_provenance" => { "judge" => judge_label, "defined_at" => defined_at }
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def kpi_definition_prompt
|
|
311
|
+
samples = @evaluation.agent.agent_runs.successful.recent.limit(5).map do |run|
|
|
312
|
+
"User: #{run.input_prompt.to_s.truncate(400)}\nAgent: #{run.output.to_s.truncate(600)}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
<<~PROMPT
|
|
316
|
+
Define evaluation KPIs for this AI agent.
|
|
317
|
+
|
|
318
|
+
The agent's system instructions (its goals):
|
|
319
|
+
---
|
|
320
|
+
#{@evaluation.agent.instructions.to_s.truncate(2_000).presence || '(no instructions configured)'}
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
Sample interactions:
|
|
324
|
+
---
|
|
325
|
+
#{samples.join("\n---\n").presence || '(no interactions recorded yet)'}
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
Define 3-#{KPI_LIMIT} measurable KPIs that capture whether the agent accomplishes its goals.
|
|
329
|
+
Each KPI must be scorable from a single interaction's output on a 0.0-1.0 scale.
|
|
330
|
+
Respond ONLY with JSON:
|
|
331
|
+
{"kpis": [{"key": "snake_case_id", "description": "what to measure", "scoring_guidance": "how to assign 0.0-1.0"}]}
|
|
332
|
+
PROMPT
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def parse_kpis(content)
|
|
336
|
+
json = content.to_s[/\{.*\}/m]
|
|
337
|
+
return [] unless json
|
|
338
|
+
|
|
339
|
+
Array(JSON.parse(json)["kpis"]).select { |kpi| kpi.is_a?(Hash) && kpi["description"].present? }
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
[]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Comparative verdict across model cohorts: the judge sees each model's
|
|
345
|
+
# per-KPI mean scores and declares which best accomplishes the goals.
|
|
346
|
+
def comparison_verdict(active_cohorts, sample_criteria, scores)
|
|
347
|
+
return nil unless judge_available?
|
|
348
|
+
|
|
349
|
+
lines = sample_criteria.map do |criterion|
|
|
350
|
+
key = criterion["key"]
|
|
351
|
+
cells = active_cohorts.keys.map do |model|
|
|
352
|
+
stats = scores.dig(key, model)
|
|
353
|
+
value = stats && stats["score"]
|
|
354
|
+
"#{model}=#{value.nil? ? 'skipped' : value} (#{stats&.dig('total') || 0} samples)"
|
|
355
|
+
end
|
|
356
|
+
"#{key}: #{cells.join(', ')}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
response = judge_class.prompt(
|
|
360
|
+
message: <<~PROMPT,
|
|
361
|
+
An AI agent was evaluated under multiple models. Its goals:
|
|
362
|
+
---
|
|
363
|
+
#{@evaluation.agent.instructions.to_s.truncate(1_000).presence || '(no instructions configured)'}
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
Per-KPI mean scores (0.0-1.0) per model:
|
|
367
|
+
#{lines.join("\n")}
|
|
368
|
+
|
|
369
|
+
Which model best accomplishes the agent's goals?
|
|
370
|
+
Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
|
|
371
|
+
PROMPT
|
|
372
|
+
instructions: "You are an impartial evaluation judge comparing model cohorts. Respond ONLY with JSON."
|
|
373
|
+
).generate_now
|
|
374
|
+
|
|
375
|
+
json = response.message&.content.to_s[/\{.*\}/m]
|
|
376
|
+
verdict = json ? JSON.parse(json) : nil
|
|
377
|
+
return nil unless verdict.is_a?(Hash) && verdict["winner"].present?
|
|
378
|
+
|
|
379
|
+
{
|
|
380
|
+
"winner" => verdict["winner"],
|
|
381
|
+
"rationale" => verdict["rationale"].to_s,
|
|
382
|
+
"judge" => @evaluation.judge_model.presence || judge_provider.to_s
|
|
383
|
+
}
|
|
384
|
+
rescue StandardError => e
|
|
385
|
+
Rails.logger.error("[EvaluationRunnerService] Verdict error: #{e.class} - #{e.message}")
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# --- LLM judge -----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def llm_judge_score(criterion, generation)
|
|
392
|
+
return nil unless judge_available?
|
|
393
|
+
return nil if generation.content.blank?
|
|
394
|
+
|
|
395
|
+
response = judge_class.prompt(
|
|
396
|
+
message: judge_prompt(criterion, generation),
|
|
397
|
+
instructions: "You are an impartial evaluation judge. Respond ONLY with JSON: {\"score\": <float between 0.0 and 1.0>}"
|
|
398
|
+
).generate_now
|
|
399
|
+
|
|
400
|
+
parse_judge_score(response.message&.content)
|
|
401
|
+
rescue StandardError => e
|
|
402
|
+
Rails.logger.error("[EvaluationRunnerService] Judge error: #{e.class} - #{e.message}")
|
|
403
|
+
nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def judge_prompt(criterion, generation)
|
|
407
|
+
<<~PROMPT
|
|
408
|
+
Criterion: #{criterion.dig('config', 'prompt').presence || criterion['key'].to_s.humanize}
|
|
409
|
+
|
|
410
|
+
Agent output to evaluate:
|
|
411
|
+
---
|
|
412
|
+
#{generation.content.to_s.truncate(4_000)}
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
Score the output against the criterion from 0.0 (fails completely) to 1.0 (fully satisfies).
|
|
416
|
+
Respond only with JSON: {"score": <float>}
|
|
417
|
+
PROMPT
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def parse_judge_score(content)
|
|
421
|
+
match = content.to_s.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/)
|
|
422
|
+
return nil unless match
|
|
423
|
+
|
|
424
|
+
match[1].to_f.clamp(0.0, 1.0)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The judge needs real provider credentials; scoring with the mock
|
|
428
|
+
# provider would fabricate results.
|
|
429
|
+
def judge_available?
|
|
430
|
+
judge_provider.present?
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def judge_provider
|
|
434
|
+
@judge_provider ||=
|
|
435
|
+
%i[anthropic openai openrouter].find do |name|
|
|
436
|
+
owner_provider_options(name).any? || global_provider_token?(name)
|
|
437
|
+
end || (:ollama if owner_provider_options(:ollama).any?)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def global_provider_token?(name)
|
|
441
|
+
config = ActiveAgent.configuration[name]
|
|
442
|
+
config.respond_to?(:[]) && config[:access_token].present?
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The evaluated agent owner's credential for +name+ (Settings ->
|
|
446
|
+
# Provider API Keys, or whatever the host app resolves); preferred over
|
|
447
|
+
# the host's config/active_agent.yml credentials for the judge.
|
|
448
|
+
def owner_provider_options(name)
|
|
449
|
+
@owner_provider_options ||= {}
|
|
450
|
+
@owner_provider_options[name.to_s] ||= begin
|
|
451
|
+
from_host = ActionAgent.provider_credentials(owner, name.to_s)
|
|
452
|
+
from_host.presence || ProviderKey.for_owner(owner).find_by(provider: name.to_s)&.generation_options || {}
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def owner
|
|
457
|
+
@owner ||= @evaluation.agent.owner
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def judge_class
|
|
461
|
+
provider = judge_provider
|
|
462
|
+
model = @evaluation.judge_model.presence
|
|
463
|
+
options = {}
|
|
464
|
+
options[:model] = model if model
|
|
465
|
+
options.merge!(owner_provider_options(provider))
|
|
466
|
+
|
|
467
|
+
@judge_class ||= Class.new(ActiveAgent::Base) do
|
|
468
|
+
define_singleton_method(:name) { "EvaluationJudgeAgent" }
|
|
469
|
+
generate_with provider, **options
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def skip_reason(criterion)
|
|
474
|
+
if criterion["type"] == "llm_judge"
|
|
475
|
+
"LLM judge requires provider credentials (add a provider API key in Settings or set ANTHROPIC_API_KEY / OPENAI_API_KEY)"
|
|
476
|
+
else
|
|
477
|
+
"No scorable samples"
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|