activeagent 1.3.1 → 1.5.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/CHANGELOG.md +421 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +258 -0
- data/lib/active_agent/evals/judge.rb +212 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +76 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +450 -0
- data/lib/active_agent/evals/report_html.rb +634 -0
- data/lib/active_agent/evals/result.rb +78 -0
- data/lib/active_agent/evals/runner.rb +204 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +265 -0
- data/lib/active_agent/evals/scorer.rb +118 -0
- data/lib/active_agent/evals/suite.rb +99 -0
- data/lib/active_agent/evals.rb +61 -0
- data/lib/active_agent/providers/_base_provider.rb +5 -1
- data/lib/active_agent/providers/mock/messages/base.rb +4 -2
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +42 -11
- data/lib/active_agent/providers/ruby_llm/options.rb +4 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +14 -1
- data/lib/active_agent/providers/rubyllm_provider.rb +1 -0
- data/lib/active_agent/telemetry/configuration.rb +11 -0
- data/lib/active_agent/telemetry/instrumentation.rb +29 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +19 -3
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Explains why a scenario did not pass and what would fix it.
|
|
6
|
+
#
|
|
7
|
+
# The fault is assigned from the replay's evidence, the most mechanical
|
|
8
|
+
# cause first, so a run that crashed is a `run_error` even if its empty
|
|
9
|
+
# answer would also have failed a content check:
|
|
10
|
+
#
|
|
11
|
+
# run_error — the run raised, or the agent returned nothing
|
|
12
|
+
# tool_error — a tool the agent called returned an error
|
|
13
|
+
# missing_capability — the agent said no tool covers the task
|
|
14
|
+
# expected_tool_not_called — the scenario expects a tool the agent did not call
|
|
15
|
+
# forbidden_content — the answer contains a pattern the scenario forbids
|
|
16
|
+
# missing_content — the answer lacks a pattern the scenario expects
|
|
17
|
+
# low_quality — the answer scored below the threshold
|
|
18
|
+
#
|
|
19
|
+
# Each fault carries a recommendation written from the evidence; a Judge
|
|
20
|
+
# can replace it with one that names the tool to add (Runner does this).
|
|
21
|
+
# Returns nil for a passing result.
|
|
22
|
+
class Diagnosis
|
|
23
|
+
FAULTS = %w[
|
|
24
|
+
run_error tool_error missing_capability expected_tool_not_called
|
|
25
|
+
forbidden_content missing_content low_quality judge_unavailable
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Phrasings an agent uses when nothing in its toolset covers the task.
|
|
29
|
+
# "find" and "see" are deliberately absent: "I can't find any…" and
|
|
30
|
+
# "I don't see…" report a negative result, not a missing capability.
|
|
31
|
+
REFUSED_VERBS = "have|access|retrieve|look up|query|check|view|search"
|
|
32
|
+
CAPABILITY_REFUSALS = [
|
|
33
|
+
/\bI(?:'m| am)? (?:do not |don't |cannot |can't |unable to |not able to )(?:currently )?(?:#{REFUSED_VERBS})\b/i,
|
|
34
|
+
/\b(?:no|don't have (?:a|any)) tools? (?:is |are )?(?:available|that can|to)\b/i,
|
|
35
|
+
/\bnot (?:something|able|possible) (?:I|to) (?:can|am able to )?(?:do|access|retrieve|look up)\b/i,
|
|
36
|
+
/\bI (?:don't|do not) have (?:the ability|a way|access|visibility|the tools?)\b/i,
|
|
37
|
+
/\boutside (?:of )?(?:my|the) (?:capabilities|available tools|scope)\b/i,
|
|
38
|
+
/\bcan(?:'|no)t (?:be )?(?:done|determined|answered) with (?:the|my) (?:current|available) tools\b/i
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
Result = Struct.new(:fault, :summary, :recommendation, :evidence, keyword_init: true) do
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
"fault" => fault,
|
|
45
|
+
"summary" => summary,
|
|
46
|
+
"recommendation" => recommendation,
|
|
47
|
+
"evidence" => evidence
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param scenario [Scenario]
|
|
53
|
+
# @param replay [Replay]
|
|
54
|
+
# @param scores [Hash] criterion key => 0.0..1.0 (nil when unscorable)
|
|
55
|
+
# @param score [Float, nil] the mean score
|
|
56
|
+
# @param available_tools [Array<String>] tool names the agent could call
|
|
57
|
+
# @param threshold [Float] the pass threshold for `score`
|
|
58
|
+
# @param agent_name [String] how the recommendations refer to the agent
|
|
59
|
+
# @param judge_keys [Array] keys in `scores` a judge graded the answer on
|
|
60
|
+
# (llm_judge criteria); `task_completion` always counts as one
|
|
61
|
+
def self.call(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
|
|
62
|
+
judge_keys: [])
|
|
63
|
+
new(scenario:, replay:, scores:, score:, available_tools:, threshold:, agent_name:, judge_keys:).call
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
|
|
67
|
+
judge_keys: [])
|
|
68
|
+
@scenario = scenario
|
|
69
|
+
@replay = replay
|
|
70
|
+
@scores = scores || {}
|
|
71
|
+
@score = score
|
|
72
|
+
@available_tools = Array(available_tools).map(&:to_s)
|
|
73
|
+
@threshold = threshold
|
|
74
|
+
@agent_name = agent_name
|
|
75
|
+
@judge_keys = Array(judge_keys) | [ "task_completion" ]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call
|
|
79
|
+
run_error || tool_error || missing_capability || expected_tool_not_called ||
|
|
80
|
+
forbidden_content || missing_content || low_quality
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def answer
|
|
86
|
+
@replay.answer.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def called_tools
|
|
90
|
+
@replay.tool_names
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def agent
|
|
94
|
+
@agent_name
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_error
|
|
98
|
+
if @replay.errored?
|
|
99
|
+
message = @replay.error.to_s
|
|
100
|
+
return result("run_error", "The run failed before #{agent.downcase} answered: #{message.truncate(200)}",
|
|
101
|
+
run_error_recommendation(message), "error" => message.truncate(1_000))
|
|
102
|
+
end
|
|
103
|
+
return nil if answer.present?
|
|
104
|
+
|
|
105
|
+
result("run_error", "#{agent} returned an empty answer.",
|
|
106
|
+
"The provider returned no content. Check the model name is one the provider serves and that the " \
|
|
107
|
+
"output budget leaves room for an answer after the tool calls.",
|
|
108
|
+
"error" => "empty answer")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_error_recommendation(message)
|
|
112
|
+
case message
|
|
113
|
+
when /credentials|api.?key|access_token|unauthori[sz]ed|401/i
|
|
114
|
+
"Add credentials for the provider this model runs on before comparing it."
|
|
115
|
+
when /model.*(not found|does not exist|unknown|unsupported)|404/i
|
|
116
|
+
"The provider rejected the model name. Check the spelling against the provider's catalog, or prefix " \
|
|
117
|
+
"it with the provider (`ollama/qwen3:8b`) so it runs where it exists."
|
|
118
|
+
when /rate limit|429|overloaded|529/i
|
|
119
|
+
"The provider throttled the run. Re-run the failed scenarios; if it recurs, run fewer scenarios per " \
|
|
120
|
+
"batch or compare fewer models at once."
|
|
121
|
+
else
|
|
122
|
+
"Inspect the run's error. A failure here is infrastructure — it says nothing about the answer yet."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def tool_error
|
|
127
|
+
failed = @replay.failed_tool_calls
|
|
128
|
+
return nil if failed.empty?
|
|
129
|
+
|
|
130
|
+
names = failed.map { |call| call["name"] }.uniq
|
|
131
|
+
detail = failed.first["detail"].to_s.truncate(300)
|
|
132
|
+
result("tool_error", "Tool #{names.join(', ')} returned an error while answering.",
|
|
133
|
+
"Fix the failing tool before judging the answer: #{names.join(', ')} errored with \"#{detail}\". " \
|
|
134
|
+
"If the arguments look wrong, tighten the tool's parameter descriptions so the model calls it " \
|
|
135
|
+
"correctly; if the tool itself broke, fix its implementation.",
|
|
136
|
+
"tools" => names, "detail" => detail, "arguments" => failed.first["arguments"])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def missing_capability
|
|
140
|
+
return nil unless CAPABILITY_REFUSALS.any? { |pattern| answer.match?(pattern) }
|
|
141
|
+
return nil if called_tools.any? && @score.to_f >= @threshold
|
|
142
|
+
|
|
143
|
+
missing = @scenario.expected_tools - @available_tools
|
|
144
|
+
recommendation =
|
|
145
|
+
if missing.any?
|
|
146
|
+
"#{agent} said it cannot do this, and the expected tool(s) #{missing.join(', ')} are not in its " \
|
|
147
|
+
"toolset. Add or enable them."
|
|
148
|
+
elsif @available_tools.empty?
|
|
149
|
+
"#{agent} has no tools, so it can only answer from its instructions. Give it a tool that reads the " \
|
|
150
|
+
"data this task needs."
|
|
151
|
+
else
|
|
152
|
+
"None of the available tools (#{@available_tools.join(', ')}) covers this task. Add a tool that does, " \
|
|
153
|
+
"or, if one of them should, rewrite its description so the model recognises when to use it."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
result("missing_capability", "#{agent} said it lacks the ability to perform this task.", recommendation,
|
|
157
|
+
"refusal" => refusal_excerpt, "tools_available" => @available_tools, "tools_called" => called_tools)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def refusal_excerpt
|
|
161
|
+
pattern = CAPABILITY_REFUSALS.find { |candidate| answer.match?(candidate) }
|
|
162
|
+
match = answer.match(pattern)
|
|
163
|
+
return nil unless match
|
|
164
|
+
|
|
165
|
+
answer[[ match.begin(0) - 80, 0 ].max, 260].to_s.strip
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def expected_tool_not_called
|
|
169
|
+
expected = @scenario.expected_tools
|
|
170
|
+
return nil if expected.empty? || (expected & called_tools).any?
|
|
171
|
+
|
|
172
|
+
unavailable = expected - @available_tools
|
|
173
|
+
recommendation =
|
|
174
|
+
if unavailable.any?
|
|
175
|
+
"The scenario expects #{unavailable.join(', ')}, which #{agent.downcase} does not have. Enable the " \
|
|
176
|
+
"tool (or add the server that provides it) and re-run."
|
|
177
|
+
elsif called_tools.any?
|
|
178
|
+
"#{agent} answered with #{called_tools.uniq.join(', ')} instead of #{expected.join(', ')}. Sharpen " \
|
|
179
|
+
"both tools' descriptions so the model can tell them apart, or say in the instructions which tool " \
|
|
180
|
+
"answers this kind of task."
|
|
181
|
+
else
|
|
182
|
+
"#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool. Tell " \
|
|
183
|
+
"it in the instructions to prefer tool-backed answers for this kind of task, and check the tool's " \
|
|
184
|
+
"description says what it returns."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
result("expected_tool_not_called",
|
|
188
|
+
"Expected #{expected.join(' or ')} to be called; #{agent.downcase} called " \
|
|
189
|
+
"#{called_tools.uniq.presence&.join(', ') || 'nothing'}.",
|
|
190
|
+
recommendation, "expected" => expected, "called" => called_tools, "unavailable" => unavailable)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def forbidden_content
|
|
194
|
+
matched = @scenario.forbidden_patterns.select { |pattern| Scorer.matches_pattern?(answer, pattern) }
|
|
195
|
+
return nil if matched.empty?
|
|
196
|
+
|
|
197
|
+
result("forbidden_content", "The answer contains content the scenario forbids: #{matched.join(', ')}.",
|
|
198
|
+
"Add an explicit instruction against \"#{matched.first}\" and, if the phrase comes from a tool " \
|
|
199
|
+
"result, filter it in the tool rather than relying on the model to omit it.",
|
|
200
|
+
"matched" => matched)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def missing_content
|
|
204
|
+
missing = @scenario.expected_patterns.reject { |pattern| Scorer.matches_pattern?(answer, pattern) }
|
|
205
|
+
return nil if missing.empty?
|
|
206
|
+
|
|
207
|
+
recommendation =
|
|
208
|
+
if called_tools.empty? && @available_tools.any?
|
|
209
|
+
"#{agent} answered without calling a tool, so it could not have found \"#{missing.first}\". " \
|
|
210
|
+
"Instruct it to use its tools for this kind of task."
|
|
211
|
+
else
|
|
212
|
+
"The answer never mentions \"#{missing.first}\". Check whether the tool result contained it — if it " \
|
|
213
|
+
"did, the instructions should ask for it explicitly; if not, the tool needs to return it."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
result("missing_content", "The answer is missing expected content: #{missing.join(', ')}.", recommendation,
|
|
217
|
+
"missing" => missing)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A judge grade — the implicit task_completion score, or the llm_judge
|
|
221
|
+
# criteria the evaluation configured — measures the answer itself, so
|
|
222
|
+
# its mean has to reach the threshold on its own. Rule checks (a tool
|
|
223
|
+
# was called, a phrase is present) cannot carry a badly graded answer.
|
|
224
|
+
def low_quality
|
|
225
|
+
grades = @scores.slice(*@judge_keys).compact
|
|
226
|
+
grade = grades.any? ? (grades.values.sum / grades.size).round(3) : nil
|
|
227
|
+
failed_grade = grade && grade < @threshold
|
|
228
|
+
return nil unless failed_grade || (@score && @score < @threshold)
|
|
229
|
+
|
|
230
|
+
weakest = (failed_grade ? grades : @scores.compact).min_by { |_, value| value }
|
|
231
|
+
summary = if failed_grade
|
|
232
|
+
"#{graded_label(grades)} scored #{grade.round(2)} against a pass threshold of #{@threshold}"
|
|
233
|
+
else
|
|
234
|
+
"Scored #{@score.round(2)} against a pass threshold of #{@threshold}"
|
|
235
|
+
end
|
|
236
|
+
summary += ", weakest on #{weakest.first} (#{weakest.last.round(2)})" if weakest
|
|
237
|
+
recommendation =
|
|
238
|
+
if weakest
|
|
239
|
+
"Read the answer against the #{weakest.first.to_s.humanize.downcase} criterion and adjust the " \
|
|
240
|
+
"instructions where it falls short. A criterion that keeps scoring low across scenarios points at " \
|
|
241
|
+
"the instructions; one that fails on one scenario points at that task's tooling."
|
|
242
|
+
else
|
|
243
|
+
"Compare this answer with a passing one for a similar scenario and adjust the instructions."
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
result("low_quality", "#{summary}.", recommendation, "scores" => @scores)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def graded_label(grades)
|
|
250
|
+
grades.keys == [ "task_completion" ] ? "Task completion" : "Judged quality"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def result(fault, summary, recommendation, evidence = {})
|
|
254
|
+
Result.new(fault: fault, summary: summary, recommendation: recommendation, evidence: evidence.compact)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# A second model that scores answers, refines recommendations, and picks a
|
|
6
|
+
# winner. The gem owns the prompts and the parsing; the caller supplies the
|
|
7
|
+
# one thing that differs per stack — how to get a completion:
|
|
8
|
+
#
|
|
9
|
+
# judge = ActiveAgent::Evals::Judge.new(label: "claude-opus-5") do |instructions:, prompt:|
|
|
10
|
+
# RubyLLM.chat(model: "claude-opus-5").with_instructions(instructions).ask(prompt).content
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Every method returns nil when the judge fails or answers unusably, so an
|
|
14
|
+
# evaluation degrades to rule scoring rather than aborting.
|
|
15
|
+
class Judge
|
|
16
|
+
SCORE_INSTRUCTIONS = "You are an impartial evaluation judge. Respond ONLY with JSON: " \
|
|
17
|
+
'{"score": <float between 0.0 and 1.0>}'
|
|
18
|
+
RECOMMEND_INSTRUCTIONS = "You diagnose why an AI agent failed a task and recommend the fix. Respond ONLY with JSON."
|
|
19
|
+
VERDICT_INSTRUCTIONS = "You are an impartial evaluation judge comparing model cohorts. Respond ONLY with JSON."
|
|
20
|
+
|
|
21
|
+
attr_reader :label
|
|
22
|
+
|
|
23
|
+
# @param label [String] how reports name the judge (usually its model)
|
|
24
|
+
# @yieldparam instructions [String] the system prompt
|
|
25
|
+
# @yieldparam prompt [String] the user prompt
|
|
26
|
+
# @yieldreturn [String] the completion text
|
|
27
|
+
def initialize(label:, &generate)
|
|
28
|
+
raise ArgumentError, "Judge.new needs a block that returns the model's completion" unless generate
|
|
29
|
+
|
|
30
|
+
@label = label
|
|
31
|
+
@generate = generate
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Scores `answer` against one llm_judge criterion, 0.0..1.0.
|
|
35
|
+
def score_criterion(criterion:, prompt:, answer:)
|
|
36
|
+
return nil if answer.blank?
|
|
37
|
+
|
|
38
|
+
guidance = criterion.dig("config", "prompt").presence || criterion["key"].to_s.humanize
|
|
39
|
+
parse_score(ask(SCORE_INSTRUCTIONS, <<~PROMPT))
|
|
40
|
+
Criterion: #{guidance}
|
|
41
|
+
|
|
42
|
+
The user asked:
|
|
43
|
+
---
|
|
44
|
+
#{prompt.to_s.truncate(1_500)}
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
The agent answered:
|
|
48
|
+
---
|
|
49
|
+
#{answer.to_s.truncate(4_000)}
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
Score the answer against the criterion from 0.0 (fails completely) to 1.0 (fully satisfies).
|
|
53
|
+
Respond only with JSON: {"score": <float>}
|
|
54
|
+
PROMPT
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Scores how well `answer` accomplishes the scenario's task, 0.0..1.0 —
|
|
58
|
+
# the single-criterion judgement for a scenario with no criteria of its own.
|
|
59
|
+
def score_task(scenario:, answer:)
|
|
60
|
+
return nil if answer.blank?
|
|
61
|
+
|
|
62
|
+
parse_score(ask(SCORE_INSTRUCTIONS, <<~PROMPT))
|
|
63
|
+
A user asked an assistant:
|
|
64
|
+
---
|
|
65
|
+
#{scenario.prompt}
|
|
66
|
+
---
|
|
67
|
+
#{"Context for the evaluator: #{scenario.notes.truncate(300)}\n" if scenario.notes.present?}
|
|
68
|
+
The assistant answered:
|
|
69
|
+
---
|
|
70
|
+
#{answer.to_s.truncate(4_000)}
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
Score from 0.0 (the task was not done — a refusal, a guess, or an unrelated answer) to 1.0 (the task
|
|
74
|
+
was done with specific, tool-backed data and a clear next step for the user).
|
|
75
|
+
Respond only with JSON: {"score": <float>}
|
|
76
|
+
PROMPT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Asks what to change so the scenario passes. Returns a hash with any of
|
|
80
|
+
# `recommendation`, `suggested_tool` (`{ "name", "description" }`) and
|
|
81
|
+
# `instruction_change`, or nil.
|
|
82
|
+
def recommend(scenario:, replay:, diagnosis:, available_tools: {}, instructions: nil)
|
|
83
|
+
roster = available_tools.to_h.map { |name, description| "- #{name}: #{description.to_s.truncate(160)}" }.join("\n")
|
|
84
|
+
calls = replay.tool_calls.map do |call|
|
|
85
|
+
"- #{call['name']}#{' (errored)' if call['error']}: #{call['arguments'].to_json.truncate(200)}"
|
|
86
|
+
end.join("\n")
|
|
87
|
+
|
|
88
|
+
parsed = parse_object(ask(RECOMMEND_INSTRUCTIONS, <<~PROMPT))
|
|
89
|
+
An AI agent failed one evaluation scenario. Recommend the fix.
|
|
90
|
+
|
|
91
|
+
Agent instructions:
|
|
92
|
+
---
|
|
93
|
+
#{instructions.to_s.truncate(2_000).presence || '(no instructions configured)'}
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
Tools available to the agent:
|
|
97
|
+
#{roster.presence || '(none)'}
|
|
98
|
+
|
|
99
|
+
Scenario (the user's message):
|
|
100
|
+
#{scenario.prompt}
|
|
101
|
+
#{"Expected tools: #{scenario.expected_tools.join(', ')}" if scenario.expected_tools.any?}
|
|
102
|
+
#{"Notes: #{scenario.notes.truncate(300)}" if scenario.notes.present?}
|
|
103
|
+
|
|
104
|
+
Tools the agent called:
|
|
105
|
+
#{calls.presence || '(none)'}
|
|
106
|
+
|
|
107
|
+
The agent's answer:
|
|
108
|
+
---
|
|
109
|
+
#{replay.answer.to_s.truncate(2_500).presence || '(empty)'}
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
Detected fault: #{diagnosis.fault} — #{diagnosis.summary}
|
|
113
|
+
|
|
114
|
+
Say what to change so this scenario passes. If the agent lacks a tool for the task, describe the
|
|
115
|
+
tool to add. If the tools suffice, say what to change in the instructions.
|
|
116
|
+
Respond ONLY with JSON:
|
|
117
|
+
{"recommendation": "<two sentences at most>",
|
|
118
|
+
"suggested_tool": {"name": "snake_case_name", "description": "what it returns"} or null,
|
|
119
|
+
"instruction_change": "<the sentence to add or change>" or null}
|
|
120
|
+
PROMPT
|
|
121
|
+
|
|
122
|
+
parsed = parsed&.slice("recommendation", "suggested_tool", "instruction_change")&.compact
|
|
123
|
+
return nil if parsed.blank?
|
|
124
|
+
|
|
125
|
+
%w[recommendation instruction_change].each do |key|
|
|
126
|
+
parsed.delete(key) unless parsed[key].is_a?(String) && parsed[key].present?
|
|
127
|
+
end
|
|
128
|
+
parsed["suggested_tool"] = suggested_tool(parsed["suggested_tool"]) if parsed.key?("suggested_tool")
|
|
129
|
+
parsed.compact.presence
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Picks the model that best accomplishes the agent's goals from the
|
|
133
|
+
# per-model summaries (Report#summary_by_model). Returns
|
|
134
|
+
# `{ "winner", "rationale" }` or nil; a winner that is not one of the
|
|
135
|
+
# compared models is discarded.
|
|
136
|
+
def verdict(summaries, instructions: nil)
|
|
137
|
+
lines = summaries.map do |label, stats|
|
|
138
|
+
faults = (stats["faults"] || {}).map { |fault, count| "#{fault}×#{count}" }.join(", ")
|
|
139
|
+
"#{label}: pass rate #{stats['pass_rate']}%, mean score #{stats['avg_score'] || 'n/a'}, " \
|
|
140
|
+
"avg latency #{stats['avg_duration_ms'] || 'n/a'}ms, cost $#{stats['cost'] || 'n/a'}" \
|
|
141
|
+
"#{", faults: #{faults}" if faults.present?}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
parsed = parse_object(ask(VERDICT_INSTRUCTIONS, <<~PROMPT))
|
|
145
|
+
An AI agent ran the same scenarios under several models. Its goals:
|
|
146
|
+
---
|
|
147
|
+
#{instructions.to_s.truncate(1_000).presence || '(no instructions configured)'}
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
Results per model:
|
|
151
|
+
#{lines.join("\n")}
|
|
152
|
+
|
|
153
|
+
Which model best accomplishes the agent's goals, weighing task completion first and cost and
|
|
154
|
+
latency second? Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
|
|
155
|
+
PROMPT
|
|
156
|
+
|
|
157
|
+
return nil unless parsed && summaries.key?(parsed["winner"])
|
|
158
|
+
|
|
159
|
+
{ "winner" => parsed["winner"], "rationale" => parsed["rationale"].to_s }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# The judge is asked for `{ "name", "description" }`; a bare string is
|
|
165
|
+
# taken as the name, and anything else is dropped rather than rendered
|
|
166
|
+
# as an empty tool.
|
|
167
|
+
def suggested_tool(tool)
|
|
168
|
+
case tool
|
|
169
|
+
when Hash
|
|
170
|
+
if tool["name"].is_a?(String) && tool["name"].present?
|
|
171
|
+
{ "name" => tool["name"], "description" => tool["description"].is_a?(String) ? tool["description"] : "" }
|
|
172
|
+
end
|
|
173
|
+
when String
|
|
174
|
+
{ "name" => tool, "description" => "" } if tool.present?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ask(instructions, prompt)
|
|
179
|
+
@generate.call(instructions: instructions, prompt: prompt).to_s
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
warn_failure(e)
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def warn_failure(error)
|
|
186
|
+
message = "[ActiveAgent::Evals] judge #{label} failed: #{error.class}: #{error.message}"
|
|
187
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
188
|
+
Rails.logger.warn(message)
|
|
189
|
+
else
|
|
190
|
+
warn(message)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_score(content)
|
|
195
|
+
value = parse_object(content)&.dig("score")
|
|
196
|
+
return nil unless value.is_a?(Numeric) && value.finite?
|
|
197
|
+
|
|
198
|
+
value.to_f.clamp(0.0, 1.0)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_object(content)
|
|
202
|
+
json = content.to_s[/\{.*\}/m]
|
|
203
|
+
return nil unless json
|
|
204
|
+
|
|
205
|
+
parsed = JSON.parse(json)
|
|
206
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# A candidate model for a comparison run, resolved from the string a user
|
|
6
|
+
# types. `label` is that string verbatim and keys the model's cohort in a
|
|
7
|
+
# report; `provider` and `model` are what the run executes under:
|
|
8
|
+
#
|
|
9
|
+
# "anthropic/claude-sonnet-5" → anthropic, claude-sonnet-5
|
|
10
|
+
# "claude-haiku-4-5" → anthropic (inferred), claude-haiku-4-5
|
|
11
|
+
# "gpt-5-mini" → openai (inferred), gpt-5-mini
|
|
12
|
+
# "qwen3:8b" → ollama (inferred from the tag), qwen3:8b
|
|
13
|
+
# "meta-llama/llama-3.3-70b" → openrouter, meta-llama/llama-3.3-70b
|
|
14
|
+
# "openrouter/anthropic/claude-3" → openrouter, anthropic/claude-3
|
|
15
|
+
#
|
|
16
|
+
# `providers` lists the names a leading path segment may name; a vendor
|
|
17
|
+
# prefix that is not one of them routes through openrouter when that is
|
|
18
|
+
# available and otherwise stays part of the model name. A bare name no
|
|
19
|
+
# inference rule recognises runs under `default_provider`.
|
|
20
|
+
class ModelSpec
|
|
21
|
+
DEFAULT_PROVIDERS = %w[openai anthropic ollama openrouter].freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_INFERENCE_RULES = [
|
|
24
|
+
[ /\Aclaude/i, "anthropic" ],
|
|
25
|
+
[ /\A(gpt-|o\d|chatgpt|text-embedding)/i, "openai" ],
|
|
26
|
+
[ /:/, "ollama" ]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :label, :provider, :model
|
|
30
|
+
|
|
31
|
+
def self.parse(value, default_provider:, providers: DEFAULT_PROVIDERS, inference_rules: DEFAULT_INFERENCE_RULES)
|
|
32
|
+
raw = value.to_s.strip
|
|
33
|
+
raise ArgumentError, "model name is blank" if raw.blank?
|
|
34
|
+
|
|
35
|
+
head, rest = raw.split("/", 2)
|
|
36
|
+
if rest.present? && providers.include?(head)
|
|
37
|
+
new(label: raw, provider: head, model: rest)
|
|
38
|
+
elsif rest.present? && providers.include?("openrouter")
|
|
39
|
+
new(label: raw, provider: "openrouter", model: raw)
|
|
40
|
+
else
|
|
41
|
+
new(label: raw, provider: infer_provider(raw, default_provider, inference_rules, providers), model: raw)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Parses a comma-separated string or an array, dropping blanks and
|
|
46
|
+
# duplicates by label.
|
|
47
|
+
def self.parse_all(values, **options)
|
|
48
|
+
values = values.to_s.split(",") unless values.is_a?(Array)
|
|
49
|
+
values.map { |value| value.to_s.strip }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The provider a bare model name runs under. A rule whose provider the
|
|
53
|
+
# caller does not offer is skipped, so an app without Ollama does not
|
|
54
|
+
# route `name:tag` there.
|
|
55
|
+
def self.infer_provider(model, default_provider, inference_rules, providers)
|
|
56
|
+
rule = inference_rules.find { |pattern, provider| model.match?(pattern) && providers.include?(provider) }
|
|
57
|
+
(rule ? rule.last : default_provider).to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(label:, provider:, model:)
|
|
61
|
+
@label = label
|
|
62
|
+
@provider = provider.to_s
|
|
63
|
+
@model = model
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
{ "label" => label, "provider" => provider, "model" => model }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ==(other)
|
|
71
|
+
other.is_a?(ModelSpec) && to_h == other.to_h
|
|
72
|
+
end
|
|
73
|
+
alias eql? ==
|
|
74
|
+
|
|
75
|
+
def hash
|
|
76
|
+
to_h.hash
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module ActiveAgent
|
|
9
|
+
module Evals
|
|
10
|
+
# Publishes a completed report without replaying the agent. The caller must
|
|
11
|
+
# retain run_id when retrying: compatible collectors treat that identity as
|
|
12
|
+
# immutable within the authenticated account. Delivery is blocking and does
|
|
13
|
+
# not follow redirects with the account's bearer credential.
|
|
14
|
+
class Publisher
|
|
15
|
+
DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
|
|
16
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
|
|
20
|
+
@uri = URI.parse(endpoint.to_s)
|
|
21
|
+
unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
|
|
22
|
+
raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
|
|
23
|
+
end
|
|
24
|
+
unless @uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(@uri.hostname)
|
|
25
|
+
raise ArgumentError, "Evaluation endpoint requires HTTPS except on loopback hosts"
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "Evaluation API key is required" if api_key.to_s.strip.empty?
|
|
28
|
+
|
|
29
|
+
@api_key = api_key.to_s
|
|
30
|
+
@timeout = Float(timeout)
|
|
31
|
+
@open_timeout = Float(open_timeout)
|
|
32
|
+
unless [ @timeout, @open_timeout ].all? { |value| value.finite? && value.positive? }
|
|
33
|
+
raise ArgumentError, "Evaluation delivery timeouts must be positive and finite"
|
|
34
|
+
end
|
|
35
|
+
rescue URI::InvalidURIError
|
|
36
|
+
raise ArgumentError, "Evaluation endpoint is not a valid URL"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# report may be a Report or its saved JSON hash. Full prompts, answers and
|
|
40
|
+
# tool results are included; applications should make publication opt-in.
|
|
41
|
+
def call(report:, run_id:, source:, agent_name:, suite:)
|
|
42
|
+
identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
|
|
43
|
+
identities.each do |key, value|
|
|
44
|
+
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
45
|
+
end
|
|
46
|
+
body = JSON.generate(identities.merge("version" => 1, "report" => report.to_h))
|
|
47
|
+
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
48
|
+
|
|
49
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port)
|
|
50
|
+
http.use_ssl = @uri.scheme == "https"
|
|
51
|
+
http.open_timeout = @open_timeout
|
|
52
|
+
http.read_timeout = @timeout
|
|
53
|
+
http.write_timeout = @timeout
|
|
54
|
+
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
55
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
56
|
+
request["Content-Type"] = "application/json"
|
|
57
|
+
request["Accept"] = "application/json"
|
|
58
|
+
request.body = body
|
|
59
|
+
response = http.request(request)
|
|
60
|
+
unless %w[200 201].include?(response.code)
|
|
61
|
+
raise Error, "Evaluation delivery rejected (HTTP #{response.code}); retain the report and run_id for retry"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
receipt = JSON.parse(response.body)
|
|
65
|
+
unless receipt.is_a?(Hash) && receipt["run_id"] == run_id && receipt["status"] == "complete" && receipt["id"] && receipt["evaluation_id"]
|
|
66
|
+
raise Error, "Evaluation collector returned an invalid completion receipt; retain the report and run_id for retry"
|
|
67
|
+
end
|
|
68
|
+
receipt
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
raise Error, "Evaluation collector returned invalid JSON; retain the report and run_id for retry"
|
|
71
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
|
|
72
|
+
raise Error, "Evaluation delivery failed (#{e.class}); retain the report and run_id for retry"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|