activeagent 1.3.0 → 1.4.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 +158 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +238 -0
- data/lib/active_agent/evals/judge.rb +205 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +447 -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 +149 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +215 -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 +60 -0
- 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 +26 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +22 -4
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
parsed["suggested_tool"] = suggested_tool(parsed["suggested_tool"]) if parsed.key?("suggested_tool")
|
|
126
|
+
parsed.compact.presence
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Picks the model that best accomplishes the agent's goals from the
|
|
130
|
+
# per-model summaries (Report#summary_by_model). Returns
|
|
131
|
+
# `{ "winner", "rationale" }` or nil; a winner that is not one of the
|
|
132
|
+
# compared models is discarded.
|
|
133
|
+
def verdict(summaries, instructions: nil)
|
|
134
|
+
lines = summaries.map do |label, stats|
|
|
135
|
+
faults = (stats["faults"] || {}).map { |fault, count| "#{fault}×#{count}" }.join(", ")
|
|
136
|
+
"#{label}: pass rate #{stats['pass_rate']}%, mean score #{stats['avg_score'] || 'n/a'}, " \
|
|
137
|
+
"avg latency #{stats['avg_duration_ms'] || 'n/a'}ms, cost $#{stats['cost'] || 'n/a'}" \
|
|
138
|
+
"#{", faults: #{faults}" if faults.present?}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
parsed = parse_object(ask(VERDICT_INSTRUCTIONS, <<~PROMPT))
|
|
142
|
+
An AI agent ran the same scenarios under several models. Its goals:
|
|
143
|
+
---
|
|
144
|
+
#{instructions.to_s.truncate(1_000).presence || '(no instructions configured)'}
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
Results per model:
|
|
148
|
+
#{lines.join("\n")}
|
|
149
|
+
|
|
150
|
+
Which model best accomplishes the agent's goals, weighing task completion first and cost and
|
|
151
|
+
latency second? Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
|
|
152
|
+
PROMPT
|
|
153
|
+
|
|
154
|
+
return nil unless parsed && summaries.key?(parsed["winner"])
|
|
155
|
+
|
|
156
|
+
{ "winner" => parsed["winner"], "rationale" => parsed["rationale"].to_s }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# The judge is asked for `{ "name", "description" }`; a bare string is
|
|
162
|
+
# taken as the name, and anything else is dropped rather than rendered
|
|
163
|
+
# as an empty tool.
|
|
164
|
+
def suggested_tool(tool)
|
|
165
|
+
case tool
|
|
166
|
+
when Hash
|
|
167
|
+
{ "name" => tool["name"].to_s, "description" => tool["description"].to_s } if tool["name"].present?
|
|
168
|
+
when String
|
|
169
|
+
{ "name" => tool, "description" => "" } if tool.present?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def ask(instructions, prompt)
|
|
174
|
+
@generate.call(instructions: instructions, prompt: prompt).to_s
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
warn_failure(e)
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def warn_failure(error)
|
|
181
|
+
message = "[ActiveAgent::Evals] judge #{label} failed: #{error.class}: #{error.message}"
|
|
182
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
183
|
+
Rails.logger.warn(message)
|
|
184
|
+
else
|
|
185
|
+
warn(message)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_score(content)
|
|
190
|
+
match = content.to_s.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/)
|
|
191
|
+
match && match[1].to_f.clamp(0.0, 1.0)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_object(content)
|
|
195
|
+
json = content.to_s[/\{.*\}/m]
|
|
196
|
+
return nil unless json
|
|
197
|
+
|
|
198
|
+
parsed = JSON.parse(json)
|
|
199
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# What one run of the agent on one scenario produced — the value the
|
|
6
|
+
# Runner's `replay` callable returns.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute answer
|
|
9
|
+
# @return [String, nil] the agent's final answer
|
|
10
|
+
# @!attribute tool_calls
|
|
11
|
+
# @return [Array<Hash>] one hash per call: `"name"`, and optionally
|
|
12
|
+
# `"arguments"`, `"error"` (true when the tool failed), `"detail"`
|
|
13
|
+
# (the error or a result preview), `"duration_ms"`
|
|
14
|
+
# @!attribute error
|
|
15
|
+
# @return [String, nil] the exception when the run raised before answering
|
|
16
|
+
# @!attribute cost
|
|
17
|
+
# @return [Numeric, nil] estimated spend, when the caller prices tokens
|
|
18
|
+
# @!attribute metadata
|
|
19
|
+
# @return [Hash] anything the caller wants carried onto the Result (a run id, a chat id)
|
|
20
|
+
Replay = Struct.new(:answer, :tool_calls, :duration_ms, :input_tokens, :output_tokens, :error, :cost, :metadata,
|
|
21
|
+
keyword_init: true) do
|
|
22
|
+
def initialize(answer: nil, tool_calls: [], duration_ms: nil, input_tokens: nil, output_tokens: nil,
|
|
23
|
+
error: nil, cost: nil, metadata: {})
|
|
24
|
+
super(
|
|
25
|
+
answer: answer,
|
|
26
|
+
tool_calls: Array(tool_calls).map { |call| call.respond_to?(:to_h) ? call.to_h.stringify_keys : { "name" => call.to_s } },
|
|
27
|
+
duration_ms: duration_ms,
|
|
28
|
+
input_tokens: input_tokens,
|
|
29
|
+
output_tokens: output_tokens,
|
|
30
|
+
error: error&.to_s,
|
|
31
|
+
cost: cost,
|
|
32
|
+
metadata: (metadata || {}).to_h
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Builds a Replay for a run that raised, so a failing scenario is scored
|
|
37
|
+
# and diagnosed like any other rather than aborting the evaluation.
|
|
38
|
+
def self.failed(error, **attributes)
|
|
39
|
+
new(error: error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s, **attributes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def errored?
|
|
43
|
+
error.present?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tool_names
|
|
47
|
+
tool_calls.map { |call| call["name"].to_s }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failed_tool_calls
|
|
51
|
+
tool_calls.select { |call| call["error"] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def total_tokens
|
|
55
|
+
input_tokens.to_i + output_tokens.to_i
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
super.compact
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|