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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. 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