activeagent 1.4.0 → 1.5.2
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 +364 -0
- data/lib/active_agent/evals/diagnosis.rb +28 -8
- data/lib/active_agent/evals/judge.rb +10 -3
- data/lib/active_agent/evals/model_spec.rb +15 -1
- data/lib/active_agent/evals/publisher.rb +76 -0
- data/lib/active_agent/evals/report.rb +5 -2
- data/lib/active_agent/evals/report_html.rb +2 -2
- data/lib/active_agent/evals/runner.rb +60 -5
- data/lib/active_agent/evals/scenario_parser.rb +60 -10
- data/lib/active_agent/evals.rb +1 -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/railtie.rb +10 -0
- data/lib/active_agent/schema_tools.rb +438 -0
- data/lib/active_agent/telemetry/instrumentation.rb +3 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- metadata +4 -2
|
@@ -8,8 +8,10 @@ module ActiveAgent
|
|
|
8
8
|
# The one thing the runner does not know is how to talk to your agent;
|
|
9
9
|
# `replay` is a callable `(scenario, model_spec) → Replay` (a Hash with the
|
|
10
10
|
# same keys is accepted, and an exception becomes an errored Replay). A
|
|
11
|
-
# scenario passes when its replay completed, met its expectations, and
|
|
12
|
-
# mean score
|
|
11
|
+
# scenario passes when its replay completed, met its expectations, and both
|
|
12
|
+
# its mean score and the mean of its judge grades (task completion, or the
|
|
13
|
+
# configured llm_judge criteria) reached `threshold`;
|
|
14
|
+
# anything else carries exactly one fault
|
|
13
15
|
# and a recommendation from Diagnosis, refined by the `judge` for the
|
|
14
16
|
# faults in `refine_faults` (up to `judge_limit` calls per run).
|
|
15
17
|
#
|
|
@@ -42,9 +44,17 @@ module ActiveAgent
|
|
|
42
44
|
# @param instructions [String, nil] the agent's instructions, for the judge
|
|
43
45
|
# @param agent_name [String] how recommendations refer to the agent
|
|
44
46
|
# @param on_result [#call, nil] called with each Result as it lands
|
|
47
|
+
# @param around_evaluation [#call, nil] called with (scenario, spec) and a
|
|
48
|
+
# block that returns the Result. Establishes context for replay, scoring
|
|
49
|
+
# and recommendations; must return the block's result. Wrapper errors
|
|
50
|
+
# propagate to the caller. Applies to #call, not direct #evaluate calls.
|
|
51
|
+
# @param require_judge_scores [Boolean] fail an otherwise passing result
|
|
52
|
+
# when a requested task/LLM grade is unavailable, rather than falling
|
|
53
|
+
# back to rule scores. Does not require a judge for rules-only runs.
|
|
45
54
|
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
46
55
|
instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
|
|
47
|
-
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
56
|
+
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
57
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {})
|
|
48
58
|
@scenarios = scenarios
|
|
49
59
|
@models = models
|
|
50
60
|
@replay = replay
|
|
@@ -58,6 +68,8 @@ module ActiveAgent
|
|
|
58
68
|
@refine_faults = refine_faults
|
|
59
69
|
@judge_limit = judge_limit
|
|
60
70
|
@on_result = on_result
|
|
71
|
+
@around_evaluation = around_evaluation
|
|
72
|
+
@require_judge_scores = require_judge_scores
|
|
61
73
|
@metadata = metadata
|
|
62
74
|
@judge_calls = 0
|
|
63
75
|
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
@@ -66,7 +78,7 @@ module ActiveAgent
|
|
|
66
78
|
def call
|
|
67
79
|
results = @scenarios.flat_map do |scenario|
|
|
68
80
|
@models.map do |spec|
|
|
69
|
-
|
|
81
|
+
evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
|
|
70
82
|
end
|
|
71
83
|
end
|
|
72
84
|
|
|
@@ -85,7 +97,9 @@ module ActiveAgent
|
|
|
85
97
|
score = Scorer.mean(scores)
|
|
86
98
|
|
|
87
99
|
diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
|
|
88
|
-
available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name
|
|
100
|
+
available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name,
|
|
101
|
+
judge_keys: llm_judge_keys)
|
|
102
|
+
diagnosis ||= unavailable_judge_diagnosis(scores)
|
|
89
103
|
diagnosis_hash = diagnosis&.to_h
|
|
90
104
|
refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
|
|
91
105
|
|
|
@@ -102,10 +116,51 @@ module ActiveAgent
|
|
|
102
116
|
|
|
103
117
|
private
|
|
104
118
|
|
|
119
|
+
def evaluate_with_context(scenario, spec)
|
|
120
|
+
return evaluate(scenario, spec) unless @around_evaluation
|
|
121
|
+
|
|
122
|
+
result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
|
|
123
|
+
# A wrapper written the natural way — do something, yield, do something
|
|
124
|
+
# after — returns that last value rather than the Result. Left alone it
|
|
125
|
+
# reaches on_result and the Report, and fails somewhere far from the
|
|
126
|
+
# wrapper that caused it. Name the wrapper here instead.
|
|
127
|
+
unless result.is_a?(Result)
|
|
128
|
+
raise ArgumentError, "around_evaluation must return the Result its block yields, got #{result.class}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
105
134
|
def judge_task?
|
|
106
135
|
@judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
|
|
107
136
|
end
|
|
108
137
|
|
|
138
|
+
# The keys in `scores` a judge graded, so a low grade is not averaged
|
|
139
|
+
# away against rule checks.
|
|
140
|
+
def llm_judge_keys
|
|
141
|
+
@llm_judge_keys ||= @criteria.filter_map do |criterion|
|
|
142
|
+
value = criterion.to_h.stringify_keys
|
|
143
|
+
value["key"] if value["type"] == "llm_judge"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unavailable_judge_diagnosis(scores)
|
|
148
|
+
return unless @require_judge_scores
|
|
149
|
+
|
|
150
|
+
keys = llm_judge_keys.dup
|
|
151
|
+
keys << "task_completion" if judge_task?
|
|
152
|
+
missing = keys.select { |key| scores[key].nil? }
|
|
153
|
+
return if missing.empty?
|
|
154
|
+
|
|
155
|
+
Diagnosis::Result.new(
|
|
156
|
+
fault: "judge_unavailable",
|
|
157
|
+
summary: "The evaluation judge did not return a usable score for #{missing.join(', ')}.",
|
|
158
|
+
recommendation: "Check the judge's credentials, model availability and JSON response, then re-run this evaluation. " \
|
|
159
|
+
"The available rule scores do not establish answer quality.",
|
|
160
|
+
evidence: { "unscored_criteria" => missing }
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
109
164
|
# Whatever the callable raises becomes an errored Replay, so one model
|
|
110
165
|
# rejecting a parameter fails its scenario rather than the whole run.
|
|
111
166
|
# A return value that is neither a Replay nor a Hash is the caller's
|
|
@@ -15,11 +15,15 @@ module ActiveAgent
|
|
|
15
15
|
# options on a line
|
|
16
16
|
# - a JSON array of strings, or of objects with `prompt` (or `message`),
|
|
17
17
|
# `group`, `key`, `notes`, `tools`, `contains`, `not_contains`
|
|
18
|
+
# - a grouped Suite document in YAML or JSON, retaining its expectations,
|
|
19
|
+
# group names, stable keys, notes and production-only flags
|
|
18
20
|
#
|
|
19
21
|
# Every scenario gets a key unique within the paste, derived from its group
|
|
20
22
|
# and position ("blame_3"), unless the line names one. The result is an
|
|
21
23
|
# array of string-keyed hashes; `Scenario.from_hash` builds the structs.
|
|
22
24
|
class ScenarioParser
|
|
25
|
+
class ParseError < ArgumentError; end
|
|
26
|
+
|
|
23
27
|
LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
|
|
24
28
|
HEADING = /\A\s*#+\s+(.+?)\s*\z/
|
|
25
29
|
BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
|
|
@@ -28,13 +32,13 @@ module ActiveAgent
|
|
|
28
32
|
BACKTICK_PROMPT = /\A`([^`]+)`/
|
|
29
33
|
OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
|
|
30
34
|
|
|
31
|
-
def self.parse(text)
|
|
32
|
-
new(text).parse
|
|
35
|
+
def self.parse(text, include_production_only: true)
|
|
36
|
+
new(text).parse(include_production_only: include_production_only)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Parses and builds Scenario structs in one step.
|
|
36
|
-
def self.scenarios(text)
|
|
37
|
-
parse(text).map { |attrs| Scenario.from_hash(attrs) }
|
|
40
|
+
def self.scenarios(text, include_production_only: true)
|
|
41
|
+
parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def initialize(text)
|
|
@@ -42,12 +46,19 @@ module ActiveAgent
|
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# @return [Array<Hash>] scenario attributes with string keys
|
|
45
|
-
def parse
|
|
49
|
+
def parse(include_production_only: true)
|
|
46
50
|
stripped = @text.strip
|
|
47
51
|
return [] if stripped.empty?
|
|
48
52
|
|
|
49
|
-
scenarios = json?(stripped)
|
|
50
|
-
|
|
53
|
+
scenarios = if json?(stripped)
|
|
54
|
+
parse_json(stripped)
|
|
55
|
+
elsif stripped.match?(/^(?:suite|groups):(?:\s|$)/)
|
|
56
|
+
parse_suite_yaml(stripped)
|
|
57
|
+
else
|
|
58
|
+
parse_lines(stripped)
|
|
59
|
+
end
|
|
60
|
+
assigned = assign_keys(scenarios)
|
|
61
|
+
include_production_only ? assigned : assigned.reject { |entry| entry["production_only"] }
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
private
|
|
@@ -58,6 +69,8 @@ module ActiveAgent
|
|
|
58
69
|
|
|
59
70
|
def parse_json(text)
|
|
60
71
|
parsed = JSON.parse(text)
|
|
72
|
+
return parse_suite(parsed) if parsed.is_a?(Hash) && parsed.key?("groups")
|
|
73
|
+
|
|
61
74
|
parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
|
|
62
75
|
parsed = [ parsed ] if parsed.is_a?(Hash)
|
|
63
76
|
|
|
@@ -71,6 +84,39 @@ module ActiveAgent
|
|
|
71
84
|
parse_lines(text)
|
|
72
85
|
end
|
|
73
86
|
|
|
87
|
+
def parse_suite_yaml(text)
|
|
88
|
+
document = YAML.safe_load(text, aliases: true)
|
|
89
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document.is_a?(Hash) && document["groups"].is_a?(Array)
|
|
90
|
+
|
|
91
|
+
parse_suite(document)
|
|
92
|
+
rescue Psych::Exception => e
|
|
93
|
+
raise ParseError, "invalid evaluation suite YAML: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_suite(document)
|
|
97
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document["groups"].is_a?(Array)
|
|
98
|
+
|
|
99
|
+
document["groups"].each do |group|
|
|
100
|
+
unless group.is_a?(Hash) && (group["scenarios"].nil? || group["scenarios"].is_a?(Array))
|
|
101
|
+
raise ParseError, "each evaluation group must contain a scenarios array"
|
|
102
|
+
end
|
|
103
|
+
Array(group["scenarios"]).each do |entry|
|
|
104
|
+
unless entry.is_a?(Hash) && entry["prompt"].is_a?(String) && entry["prompt"].present?
|
|
105
|
+
raise ParseError, "each evaluation scenario must contain a prompt"
|
|
106
|
+
end
|
|
107
|
+
expectations = entry["expectations"] || entry["expect"]
|
|
108
|
+
if expectations && !expectations.is_a?(Hash)
|
|
109
|
+
raise ParseError, "scenario expectations must be an object"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Suite.new([ document ]).all_scenarios.map do |item|
|
|
115
|
+
scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
|
|
116
|
+
notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
74
120
|
def scenario_from_hash(entry)
|
|
75
121
|
entry = entry.stringify_keys
|
|
76
122
|
prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
|
|
@@ -84,9 +130,11 @@ module ActiveAgent
|
|
|
84
130
|
scenario(
|
|
85
131
|
prompt: prompt,
|
|
86
132
|
group: entry["group"],
|
|
133
|
+
group_name: entry["group_name"],
|
|
87
134
|
key: entry["key"],
|
|
88
135
|
notes: entry["notes"],
|
|
89
|
-
expectations: expectations
|
|
136
|
+
expectations: expectations,
|
|
137
|
+
production_only: entry["production_only"] == true
|
|
90
138
|
)
|
|
91
139
|
end
|
|
92
140
|
|
|
@@ -172,13 +220,15 @@ module ActiveAgent
|
|
|
172
220
|
text.to_s.gsub(/\*\*|__|`/, "").strip
|
|
173
221
|
end
|
|
174
222
|
|
|
175
|
-
def scenario(prompt:, group: nil, key: nil, notes: nil, expectations: {})
|
|
223
|
+
def scenario(prompt:, group: nil, group_name: nil, key: nil, notes: nil, expectations: {}, production_only: false)
|
|
176
224
|
{
|
|
177
225
|
"prompt" => prompt.to_s.strip,
|
|
178
226
|
"group" => group.presence&.to_s&.strip,
|
|
227
|
+
"group_name" => group_name.presence&.to_s&.strip,
|
|
179
228
|
"key" => key.presence&.to_s&.strip,
|
|
180
229
|
"notes" => notes.presence,
|
|
181
|
-
"expectations" => (expectations || {}).reject { |_, value| value.blank? }
|
|
230
|
+
"expectations" => (expectations || {}).reject { |_, value| value.blank? },
|
|
231
|
+
"production_only" => production_only
|
|
182
232
|
}
|
|
183
233
|
end
|
|
184
234
|
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -59,7 +59,8 @@ module ActiveAgent
|
|
|
59
59
|
:tools_function, # Callback (Tools)
|
|
60
60
|
:usage_stack, # Usage Tracking
|
|
61
61
|
:stream_usage_index, # Usage Tracking (Streams)
|
|
62
|
-
:max_tool_turns, :tool_turns
|
|
62
|
+
:max_tool_turns, :tool_turns, # Tool-loop safety
|
|
63
|
+
:instrumentation_enabled # Per-generation privacy
|
|
63
64
|
|
|
64
65
|
# Upper bound on tool-calling round-trips within one generation. A
|
|
65
66
|
# model that keeps emitting tool calls otherwise recurses until the
|
|
@@ -117,6 +118,7 @@ module ActiveAgent
|
|
|
117
118
|
self.tools_function = kwargs.delete(:tools_function)
|
|
118
119
|
self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
|
|
119
120
|
self.tool_turns = 0
|
|
121
|
+
self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
|
|
120
122
|
self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
|
|
121
123
|
self.context = kwargs
|
|
122
124
|
self.message_stack = []
|
|
@@ -175,6 +177,8 @@ module ActiveAgent
|
|
|
175
177
|
# @yield block to instrument
|
|
176
178
|
# @return [Object] block result
|
|
177
179
|
def instrument(name, payload = {}, &block)
|
|
180
|
+
return block&.call(payload) unless instrumentation_enabled
|
|
181
|
+
|
|
178
182
|
full_payload = { provider: service_name, provider_module: tag_name, trace_id: }.merge(payload)
|
|
179
183
|
ActiveSupport::Notifications.instrument(name, full_payload, &block)
|
|
180
184
|
end
|
|
@@ -21,8 +21,10 @@ module ActiveAgent
|
|
|
21
21
|
if content_type == :text
|
|
22
22
|
self.content = value
|
|
23
23
|
else
|
|
24
|
-
#
|
|
25
|
-
#
|
|
24
|
+
# No vision here: an image/document stands in as a text
|
|
25
|
+
# marker, so a media-only turn still has content to validate
|
|
26
|
+
# and to concatenate with its neighbours when serialized.
|
|
27
|
+
self.content ||= "[#{content_type}]"
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -295,25 +295,43 @@ module ActiveAgent
|
|
|
295
295
|
if message.respond_to?(:serialize)
|
|
296
296
|
message.serialize
|
|
297
297
|
elsif message.is_a?(Hash)
|
|
298
|
-
# If it has a role, it's a message
|
|
298
|
+
# If it has a role, it's a message. Its :text becomes :content,
|
|
299
|
+
# and an :image / :document alongside it becomes a content
|
|
300
|
+
# part — the same `{role:, text:, image:}` shorthand the Chat
|
|
301
|
+
# API and Anthropic transforms accept, so a caller sending
|
|
302
|
+
# history plus a multimodal turn gets the same request shape
|
|
303
|
+
# from every provider.
|
|
299
304
|
if message.key?(:role)
|
|
300
305
|
normalized = message.dup
|
|
301
|
-
|
|
302
|
-
|
|
306
|
+
# The shorthand keys always come off the message: left on,
|
|
307
|
+
# they reach the request body as unknown parameters and the
|
|
308
|
+
# API rejects the whole call. A blank one contributes no
|
|
309
|
+
# part rather than an empty input_image the API would
|
|
310
|
+
# refuse (or a nil document, which has no URL to send).
|
|
311
|
+
text = normalized.delete(:text)
|
|
312
|
+
image = normalized.delete(:image)
|
|
313
|
+
document = normalized.delete(:document)
|
|
314
|
+
|
|
315
|
+
unless normalized.key?(:content)
|
|
316
|
+
parts = []
|
|
317
|
+
parts << { type: "input_text", text: text } if text.present?
|
|
318
|
+
parts << { type: "input_image", image_url: image } if image.present?
|
|
319
|
+
parts << document_part(document) if document.present?
|
|
320
|
+
|
|
321
|
+
if parts.size == 1 && parts.first[:type] == "input_text"
|
|
322
|
+
normalized[:content] = parts.first[:text]
|
|
323
|
+
elsif parts.any?
|
|
324
|
+
normalized[:content] = parts
|
|
325
|
+
end
|
|
303
326
|
end
|
|
304
327
|
return normalized
|
|
305
328
|
end
|
|
306
329
|
|
|
307
330
|
# Expand shorthand formats to full structures for content items
|
|
308
|
-
if message.
|
|
331
|
+
if message[:image].present?
|
|
309
332
|
{ type: "input_image", image_url: message[:image] }
|
|
310
|
-
elsif message.
|
|
311
|
-
|
|
312
|
-
if document_value.start_with?("data:")
|
|
313
|
-
{ type: "input_file", filename: "document.pdf", file_data: document_value }
|
|
314
|
-
else
|
|
315
|
-
{ type: "input_file", file_url: document_value }
|
|
316
|
-
end
|
|
333
|
+
elsif message[:document].present?
|
|
334
|
+
document_part(message[:document])
|
|
317
335
|
elsif message.key?(:text) && message.size == 1
|
|
318
336
|
# Single :text key without :role - treat as user message
|
|
319
337
|
{ role: "user", content: message[:text] }
|
|
@@ -336,6 +354,19 @@ module ActiveAgent
|
|
|
336
354
|
end
|
|
337
355
|
end
|
|
338
356
|
|
|
357
|
+
# An input_file part for a document given as a URL or a data URI.
|
|
358
|
+
#
|
|
359
|
+
# @param document_value [String] URL or data URI
|
|
360
|
+
# @return [Hash] input_file content part
|
|
361
|
+
def document_part(document_value)
|
|
362
|
+
document_value = document_value.to_s
|
|
363
|
+
if document_value.start_with?("data:")
|
|
364
|
+
{ type: "input_file", filename: "document.pdf", file_data: document_value }
|
|
365
|
+
else
|
|
366
|
+
{ type: "input_file", file_url: document_value }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
339
370
|
# Cleans up serialized request for API submission
|
|
340
371
|
#
|
|
341
372
|
# Removes default values and simplifies input where possible.
|
data/lib/active_agent/railtie.rb
CHANGED
|
@@ -114,6 +114,16 @@ module ActiveAgent
|
|
|
114
114
|
initializer "active_agent.inflections" do
|
|
115
115
|
ActiveSupport::Inflector.inflections do |inflect|
|
|
116
116
|
inflect.acronym "AI"
|
|
117
|
+
|
|
118
|
+
# "MCP" alone does not give the plural: an acronym only matches the
|
|
119
|
+
# whole word, so `mcps` still camelizes to `Mcps`, and a constant
|
|
120
|
+
# spelled `MCPs` underscores back to `mc_ps` — a name that round-trips
|
|
121
|
+
# to something no file is called.
|
|
122
|
+
#
|
|
123
|
+
# Registering the plural as its own acronym makes both directions
|
|
124
|
+
# agree: mcps <-> MCPs, alongside mcp_catalog <-> MCPCatalog.
|
|
125
|
+
inflect.acronym "MCP"
|
|
126
|
+
inflect.acronym "MCPs"
|
|
117
127
|
end
|
|
118
128
|
end
|
|
119
129
|
|