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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1058 -0
  3. data/README.md +9 -1
  4. data/lib/active_agent/base.rb +22 -1
  5. data/lib/active_agent/concerns/delegation.rb +4 -0
  6. data/lib/active_agent/concerns/input_requests.rb +178 -0
  7. data/lib/active_agent/concerns/parameterized.rb +7 -4
  8. data/lib/active_agent/concerns/provider.rb +1 -0
  9. data/lib/active_agent/concerns/tooling.rb +7 -2
  10. data/lib/active_agent/delegation/runner.rb +31 -0
  11. data/lib/active_agent/evals/catalog.rb +305 -0
  12. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  13. data/lib/active_agent/evals/scenario.rb +10 -3
  14. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  15. data/lib/active_agent/evals.rb +1 -0
  16. data/lib/active_agent/generation.rb +62 -2
  17. data/lib/active_agent/generation_job.rb +14 -3
  18. data/lib/active_agent/input_request/resume.rb +199 -0
  19. data/lib/active_agent/input_request.rb +279 -0
  20. data/lib/active_agent/providers/_base_provider.rb +153 -31
  21. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  22. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  23. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  24. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  25. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  26. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  27. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  28. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  29. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  30. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  31. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  32. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  33. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  34. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  35. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  36. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  37. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  38. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  39. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  40. data/lib/active_agent/providers/open_router/options.rb +6 -2
  41. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  42. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  43. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  44. data/lib/active_agent/schema_tools.rb +12 -0
  45. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  46. data/lib/active_agent/version.rb +1 -1
  47. data/lib/active_agent.rb +2 -0
  48. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  49. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  50. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  51. 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