activeagent 1.8.1 → 1.9.1
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 +1058 -0
- data/README.md +9 -1
- data/lib/active_agent/base.rb +22 -1
- data/lib/active_agent/concerns/delegation.rb +4 -0
- data/lib/active_agent/concerns/input_requests.rb +178 -0
- data/lib/active_agent/concerns/parameterized.rb +7 -4
- data/lib/active_agent/concerns/provider.rb +1 -0
- data/lib/active_agent/concerns/tooling.rb +7 -2
- data/lib/active_agent/delegation/runner.rb +31 -0
- data/lib/active_agent/evals/catalog.rb +305 -0
- data/lib/active_agent/evals/ruby_llm.rb +411 -0
- data/lib/active_agent/evals/scenario.rb +10 -3
- data/lib/active_agent/evals/scenario_parser.rb +38 -10
- data/lib/active_agent/evals.rb +1 -0
- data/lib/active_agent/generation.rb +62 -2
- data/lib/active_agent/generation_job.rb +14 -3
- data/lib/active_agent/input_request/resume.rb +199 -0
- data/lib/active_agent/input_request.rb +279 -0
- data/lib/active_agent/providers/_base_provider.rb +153 -31
- data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
- data/lib/active_agent/providers/anthropic_provider.rb +102 -20
- data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
- data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
- data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
- data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
- data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
- data/lib/active_agent/providers/deepseek/_types.rb +4 -0
- data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
- data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
- data/lib/active_agent/providers/deepseek/options.rb +53 -0
- data/lib/active_agent/providers/deepseek_provider.rb +46 -0
- data/lib/active_agent/providers/log_subscriber.rb +13 -0
- data/lib/active_agent/providers/mcp_bridge.rb +691 -0
- data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
- data/lib/active_agent/providers/open_ai/_base.rb +1 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
- data/lib/active_agent/providers/open_ai/options.rb +1 -1
- data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
- data/lib/active_agent/providers/open_router/options.rb +6 -2
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
- data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
- data/lib/active_agent/schema_tools.rb +12 -0
- data/lib/active_agent/telemetry/instrumentation.rb +10 -1
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/schema_tools/USAGE +11 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
- metadata +31 -2
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Evals
|
|
7
|
+
# A catalog of evaluation scenarios: the products an app evaluates, each
|
|
8
|
+
# with the named sets of scenarios it is tested with. One YAML document,
|
|
9
|
+
# or several layered like Suite's: a later document's product, set or
|
|
10
|
+
# scenario with a key already present replaces it, a new key is appended.
|
|
11
|
+
#
|
|
12
|
+
# catalog: support_desk
|
|
13
|
+
# name: Support Desk
|
|
14
|
+
# description: What the support agents are expected to handle
|
|
15
|
+
# products:
|
|
16
|
+
# - key: triage
|
|
17
|
+
# name: Triage agent
|
|
18
|
+
# agent: TriageAgent # the agent class, or a dashboard agent's name
|
|
19
|
+
# sets:
|
|
20
|
+
# - key: smoke
|
|
21
|
+
# name: Smoke
|
|
22
|
+
# judge: { kind: rules } # optional; an evaluation's judge_kind and judge_model
|
|
23
|
+
# criteria: [] # optional; an evaluation's criteria
|
|
24
|
+
# scenarios:
|
|
25
|
+
# - key: refund_request
|
|
26
|
+
# prompt: A customer asks for a refund on order 1042.
|
|
27
|
+
# expect:
|
|
28
|
+
# tools: [lookup_order]
|
|
29
|
+
# contains: [refund]
|
|
30
|
+
# notes: Looks the order up before answering.
|
|
31
|
+
# tags: [billing]
|
|
32
|
+
# params: { locale: en }
|
|
33
|
+
# production_only: false
|
|
34
|
+
#
|
|
35
|
+
# A suite document (`suite:`, `groups:`) loads as a catalog of one
|
|
36
|
+
# product, named after the suite, whose sets are the suite's groups, so
|
|
37
|
+
# every existing `config/evals/*.yml` is already a catalog.
|
|
38
|
+
#
|
|
39
|
+
# The catalog is the file format and nothing more: `scenarios` hands the
|
|
40
|
+
# Runner its scenarios, `suite_for` gives one product's sets to code that
|
|
41
|
+
# takes a Suite, and `to_yaml` writes the canonical document that
|
|
42
|
+
# `digest` fingerprints, so two catalogs with the same content read the
|
|
43
|
+
# same wherever they were written.
|
|
44
|
+
class Catalog
|
|
45
|
+
class NotFound < StandardError; end
|
|
46
|
+
class InvalidDocument < StandardError; end
|
|
47
|
+
|
|
48
|
+
# The keys a product, set or scenario may carry. Anything else is kept
|
|
49
|
+
# under `metadata` so a document round-trips.
|
|
50
|
+
PRODUCT_KEYS = %w[key name description agent sets].freeze
|
|
51
|
+
SET_KEYS = %w[key name description judge criteria scenarios].freeze
|
|
52
|
+
SCENARIO_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
|
|
53
|
+
CATALOG_KEYS = %w[catalog name description products suite groups].freeze
|
|
54
|
+
|
|
55
|
+
Product = Struct.new(:key, :name, :description, :agent, :metadata, :sets, :position, keyword_init: true) do
|
|
56
|
+
def set(key)
|
|
57
|
+
sets.find { |set| set.key == key.to_s }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scenarios
|
|
61
|
+
sets.flat_map(&:scenarios)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Set = Struct.new(:key, :name, :description, :judge, :criteria, :metadata, :scenarios, :position, keyword_init: true)
|
|
66
|
+
|
|
67
|
+
attr_reader :key, :name, :description, :metadata, :products
|
|
68
|
+
|
|
69
|
+
# Loads the documents at `paths` (missing files are skipped), base
|
|
70
|
+
# first; raises NotFound when none exist.
|
|
71
|
+
def self.load(*paths, name: nil)
|
|
72
|
+
existing = paths.flatten.map(&:to_s).select { |path| File.exist?(path) }
|
|
73
|
+
raise NotFound, "no scenario catalog at #{paths.flatten.join(', ')}" if existing.empty?
|
|
74
|
+
|
|
75
|
+
documents = existing.map { |path| YAML.safe_load_file(path, aliases: true) || {} }
|
|
76
|
+
new(documents, name: name || File.basename(existing.first, ".yml"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Parses YAML text. Raises InvalidDocument for YAML that is not a
|
|
80
|
+
# document (a list, a scalar) or that YAML cannot read.
|
|
81
|
+
def self.parse(text, name: nil)
|
|
82
|
+
document = YAML.safe_load(text.to_s, aliases: true, permitted_classes: [ Symbol ])
|
|
83
|
+
raise InvalidDocument, "a scenario catalog is a YAML mapping" unless document.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
new([ document ], name: name)
|
|
86
|
+
rescue Psych::SyntaxError => e
|
|
87
|
+
raise InvalidDocument, "the catalog is not valid YAML: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param documents [Hash, Array<Hash>] parsed YAML documents, base first
|
|
91
|
+
def initialize(documents, name: nil)
|
|
92
|
+
documents = Array.wrap(documents).map { |document| normalize(document.to_h.deep_stringify_keys, name) }
|
|
93
|
+
@key = documents.filter_map { |doc| doc["catalog"].presence }.last.to_s.presence || name.to_s.presence
|
|
94
|
+
raise InvalidDocument, "the catalog has no key: set `catalog:` in the document" if @key.blank?
|
|
95
|
+
|
|
96
|
+
@name = documents.filter_map { |doc| doc["name"].presence }.last || @key
|
|
97
|
+
@description = documents.filter_map { |doc| doc["description"].presence }.last
|
|
98
|
+
@metadata = documents.map { |doc| doc.except(*CATALOG_KEYS) }.reduce({}, :merge)
|
|
99
|
+
@products = build_products(merge_products(documents))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The product with +key+, or NotFound.
|
|
103
|
+
def product(key)
|
|
104
|
+
products.find { |product| product.key == key.to_s } ||
|
|
105
|
+
raise(NotFound, "no product #{key.inspect} in catalog #{self.key}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The set +set_key+ of product +product_key+, or NotFound.
|
|
109
|
+
def set(product_key, set_key)
|
|
110
|
+
product(product_key).set(set_key) ||
|
|
111
|
+
raise(NotFound, "no set #{set_key.inspect} in product #{product_key.inspect} of catalog #{key}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Scenarios across the catalog, narrowed by product key, set key,
|
|
115
|
+
# scenario keys, or any of them; `production_only` scenarios are
|
|
116
|
+
# dropped unless `include_production_only` is true. Each scenario's
|
|
117
|
+
# group is its set's key and its group_name the set's name.
|
|
118
|
+
def scenarios(product: nil, set: nil, keys: nil, include_production_only: true)
|
|
119
|
+
selected = products
|
|
120
|
+
selected = selected.select { |entry| entry.key == product.to_s } if product.present?
|
|
121
|
+
selected = selected.flat_map(&:sets)
|
|
122
|
+
selected = selected.select { |entry| entry.key == set.to_s } if set.present?
|
|
123
|
+
selected = selected.flat_map(&:scenarios)
|
|
124
|
+
selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
|
|
125
|
+
selected = selected.reject(&:production_only?) unless include_production_only
|
|
126
|
+
selected
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def all_scenarios
|
|
130
|
+
scenarios
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def scenario_count
|
|
134
|
+
products.sum { |product| product.sets.sum { |set| set.scenarios.size } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# One product's sets as a Suite (every set a group), or one set alone,
|
|
138
|
+
# for code that takes a suite: `Runner.new(scenarios: catalog.suite_for(:triage).scenarios, ...)`.
|
|
139
|
+
def suite_for(product_key, set_key = nil)
|
|
140
|
+
found = product(product_key)
|
|
141
|
+
sets = set_key ? [ set(product_key, set_key) ] : found.sets
|
|
142
|
+
Suite.new([ {
|
|
143
|
+
"suite" => [ key, found.key, set_key ].compact.join("/"),
|
|
144
|
+
"description" => found.description || description,
|
|
145
|
+
"groups" => sets.map { |entry| set_document(entry) }
|
|
146
|
+
} ])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The canonical document: keys in a fixed order, nothing empty, so the
|
|
150
|
+
# same content always reads the same.
|
|
151
|
+
def to_h
|
|
152
|
+
compact({
|
|
153
|
+
"catalog" => key,
|
|
154
|
+
"name" => name,
|
|
155
|
+
"description" => description,
|
|
156
|
+
"products" => products.map { |product| product_document(product) }
|
|
157
|
+
}.merge(metadata))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def to_yaml
|
|
161
|
+
YAML.dump(to_h)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# SHA-256 of the canonical document.
|
|
165
|
+
def digest
|
|
166
|
+
Digest::SHA256.hexdigest(to_yaml)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# A suite document becomes a catalog document of one product.
|
|
172
|
+
def normalize(document, name)
|
|
173
|
+
return document if document.key?("products") || document.key?("catalog")
|
|
174
|
+
return document unless document.key?("suite") || document.key?("groups")
|
|
175
|
+
|
|
176
|
+
product_key = document["suite"].presence || name.to_s.presence || "default"
|
|
177
|
+
{
|
|
178
|
+
"catalog" => document["catalog"].presence || product_key,
|
|
179
|
+
"name" => document["name"],
|
|
180
|
+
"description" => document["description"],
|
|
181
|
+
"products" => [ {
|
|
182
|
+
"key" => product_key,
|
|
183
|
+
"name" => document["name"].presence || product_key,
|
|
184
|
+
"description" => document["description"],
|
|
185
|
+
"sets" => Array(document["groups"])
|
|
186
|
+
} ]
|
|
187
|
+
}.compact
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def merge_products(documents)
|
|
191
|
+
documents.each_with_object([]) do |document, products|
|
|
192
|
+
Array(document["products"]).each do |incoming|
|
|
193
|
+
incoming = incoming.to_h.deep_stringify_keys
|
|
194
|
+
raise InvalidDocument, "a product has no key" if incoming["key"].blank?
|
|
195
|
+
|
|
196
|
+
existing = products.find { |product| product["key"] == incoming["key"].to_s }
|
|
197
|
+
if existing
|
|
198
|
+
%w[name description agent].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
199
|
+
existing.merge!(incoming.except(*PRODUCT_KEYS))
|
|
200
|
+
merge_sets(existing, Array(incoming["sets"]))
|
|
201
|
+
else
|
|
202
|
+
products << incoming.merge("key" => incoming["key"].to_s, "sets" => [])
|
|
203
|
+
.tap { |product| merge_sets(product, Array(incoming["sets"])) }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def merge_sets(product, incoming_sets)
|
|
210
|
+
incoming_sets.each do |incoming|
|
|
211
|
+
incoming = incoming.to_h.deep_stringify_keys
|
|
212
|
+
raise InvalidDocument, "a set of product #{product['key'].inspect} has no key" if incoming["key"].blank?
|
|
213
|
+
|
|
214
|
+
existing = product["sets"].find { |set| set["key"] == incoming["key"].to_s }
|
|
215
|
+
if existing
|
|
216
|
+
%w[name description judge criteria].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
217
|
+
existing.merge!(incoming.except(*SET_KEYS))
|
|
218
|
+
merge_scenarios(existing, Array(incoming["scenarios"]))
|
|
219
|
+
else
|
|
220
|
+
product["sets"] << incoming.merge("key" => incoming["key"].to_s, "scenarios" => [])
|
|
221
|
+
.tap { |set| merge_scenarios(set, Array(incoming["scenarios"])) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def merge_scenarios(set, incoming_scenarios)
|
|
227
|
+
incoming_scenarios.each do |scenario|
|
|
228
|
+
scenario = scenario.to_h.deep_stringify_keys
|
|
229
|
+
raise InvalidDocument, "a scenario in set #{set['key'].inspect} has no key" if scenario["key"].blank?
|
|
230
|
+
raise InvalidDocument, "scenario #{scenario['key'].inspect} has no prompt" if scenario["prompt"].blank?
|
|
231
|
+
|
|
232
|
+
scenario["key"] = scenario["key"].to_s
|
|
233
|
+
index = set["scenarios"].index { |existing| existing["key"] == scenario["key"] }
|
|
234
|
+
index ? set["scenarios"][index] = scenario : set["scenarios"] << scenario
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_products(documents)
|
|
239
|
+
documents.each_with_index.map do |product, position|
|
|
240
|
+
Product.new(
|
|
241
|
+
key: product["key"],
|
|
242
|
+
name: product["name"].presence || product["key"],
|
|
243
|
+
description: product["description"].presence,
|
|
244
|
+
agent: product["agent"].presence&.to_s,
|
|
245
|
+
metadata: product.except(*PRODUCT_KEYS),
|
|
246
|
+
position: position,
|
|
247
|
+
sets: product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position) }
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_set(set, position)
|
|
253
|
+
Set.new(
|
|
254
|
+
key: set["key"],
|
|
255
|
+
name: set["name"].presence || set["key"],
|
|
256
|
+
description: set["description"].presence,
|
|
257
|
+
judge: (set["judge"] || {}).to_h.stringify_keys,
|
|
258
|
+
criteria: Array(set["criteria"]).map { |criterion| criterion.to_h.deep_stringify_keys },
|
|
259
|
+
metadata: set.except(*SET_KEYS),
|
|
260
|
+
position: position,
|
|
261
|
+
scenarios: set["scenarios"].each_with_index.map do |entry, index|
|
|
262
|
+
Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"])
|
|
263
|
+
end
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def product_document(product)
|
|
268
|
+
compact({
|
|
269
|
+
"key" => product.key,
|
|
270
|
+
"name" => product.name,
|
|
271
|
+
"description" => product.description,
|
|
272
|
+
"agent" => product.agent,
|
|
273
|
+
"sets" => product.sets.map { |set| set_document(set) }
|
|
274
|
+
}.merge(product.metadata))
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def set_document(set)
|
|
278
|
+
compact({
|
|
279
|
+
"key" => set.key,
|
|
280
|
+
"name" => set.name,
|
|
281
|
+
"description" => set.description,
|
|
282
|
+
"judge" => set.judge,
|
|
283
|
+
"criteria" => set.criteria,
|
|
284
|
+
"scenarios" => set.scenarios.map { |scenario| scenario_document(scenario) }
|
|
285
|
+
}.merge(set.metadata))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def scenario_document(scenario)
|
|
289
|
+
compact({
|
|
290
|
+
"key" => scenario.key,
|
|
291
|
+
"prompt" => scenario.prompt,
|
|
292
|
+
"expect" => scenario.expectations,
|
|
293
|
+
"notes" => scenario.notes,
|
|
294
|
+
"tags" => scenario.tags,
|
|
295
|
+
"params" => scenario.params,
|
|
296
|
+
"production_only" => (true if scenario.production_only?)
|
|
297
|
+
})
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def compact(hash)
|
|
301
|
+
hash.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty? && value != false) }
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|