activeagent 1.9.1 → 1.9.3

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.
@@ -335,8 +335,10 @@ module ActiveAgent
335
335
  # Detect Ollama by presence of nanosecond duration fields
336
336
  if usage.key?(:total_duration)
337
337
  from_ollama(usage_hash)
338
- # Detect Anthropic by presence of cache_creation or service_tier
339
- elsif usage.key?(:cache_creation) || usage.key?(:service_tier)
338
+ # Detect Anthropic by presence of cache_creation or service_tier, or by its
339
+ # cache token fields (a usage payload does not always carry the former two)
340
+ elsif usage.key?(:cache_creation) || usage.key?(:service_tier) ||
341
+ usage.key?(:cache_read_input_tokens) || usage.key?(:cache_creation_input_tokens)
340
342
  from_anthropic(usage_hash)
341
343
  # Detect OpenAI Responses API by input_tokens/output_tokens with details
342
344
  elsif usage.key?(:input_tokens) && usage.key?(:input_tokens_details)
@@ -117,6 +117,10 @@ module ActiveAgent
117
117
  return if api_message.nil?
118
118
 
119
119
  # If we have a delta, we need to update a message in the stack
120
+ if (finish_reason = api_message.try(:finish_reason))
121
+ self.stream_finish_reason = finish_reason.to_s
122
+ end
123
+
120
124
  message = find_or_create_message(api_message.index)
121
125
  message_merge_delta(message, api_message.delta.deep_to_h)
122
126
 
@@ -143,6 +147,13 @@ module ActiveAgent
143
147
  # => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
144
148
  when :"tool_calls.function.arguments.done"
145
149
  # => => {type: :"tool_calls.function.arguments.done", index: 0, name: "get_current_weather", arguments: "{\"location\":\"Boston, MA\"}", parsed: nil}
150
+ when :"refusal.delta"
151
+ # No-Op; the refusal is merged into the message from the :chunk event
152
+ when :"refusal.done"
153
+ # A refused message ends here, with no content.done
154
+ self.stream_completion_pending = true
155
+ when :"logprobs.content.delta", :"logprobs.content.done", :"logprobs.refusal.delta", :"logprobs.refusal.done"
156
+ # No-Op; sent when the request asks for logprobs
146
157
  else
147
158
  fail "Unexpected Response Event Type: #{api_response_event.type}"
148
159
  end
@@ -256,6 +267,39 @@ module ActiveAgent
256
267
  message_stack.last
257
268
  end
258
269
 
270
+ # Finds the streamed tool call an unindexed delta continues
271
+ #
272
+ # OpenAI-compatible servers (Gemini's compatibility endpoint, some Ollama
273
+ # and OpenRouter backends) omit the `index` that ties argument fragments
274
+ # to their call. A delta carrying an `id` continues the call with that
275
+ # id; one without an id or a function name continues the last call;
276
+ # anything else starts a new call.
277
+ #
278
+ # @param tool_calls [Array<Hash>] calls accumulated so far
279
+ # @param delta_item [Hash] unindexed tool call delta
280
+ # @return [Hash, nil] the call to merge into, or nil for a new call
281
+ def unindexed_tool_call_target(tool_calls, delta_item)
282
+ if delta_item[:id].present?
283
+ tool_calls.find { _1[:id] == delta_item[:id] }
284
+ elsif delta_item.dig(:function, :name).blank?
285
+ tool_calls.last
286
+ end
287
+ end
288
+
289
+ # Strips the fields a server repeats on every fragment (`id`, `type`,
290
+ # the function name), so merging does not concatenate them.
291
+ #
292
+ # @param hash_item [Hash] the call the fragment continues
293
+ # @param delta_item [Hash] unindexed tool call delta
294
+ # @return [Hash] the fragment to merge
295
+ def unindexed_tool_call_fragment(hash_item, delta_item)
296
+ fragment = delta_item.except(:id, :type)
297
+ if fragment[:function].is_a?(Hash) && fragment.dig(:function, :name) == hash_item.dig(:function, :name)
298
+ fragment = fragment.merge(function: fragment[:function].except(:name))
299
+ end
300
+ fragment
301
+ end
302
+
259
303
  # Recursively merges delta changes into a hash structure
260
304
  #
261
305
  # Handles complex delta merging for OpenAI's streaming API, including
@@ -278,6 +322,8 @@ module ActiveAgent
278
322
  else
279
323
  hash[key] << delta_item
280
324
  end
325
+ elsif key == :tool_calls && delta_item.is_a?(Hash) && (hash_item = unindexed_tool_call_target(hash[key], delta_item))
326
+ hash_merge_delta(hash_item, unindexed_tool_call_fragment(hash_item, delta_item))
281
327
  else
282
328
  hash[key] << delta_item
283
329
  end
@@ -15,6 +15,18 @@ module ActiveAgent
15
15
  class ResponsesProvider < Base
16
16
  include ToolChoiceClearing
17
17
 
18
+ # Output items of OpenAI's built-in tools (web search, file search,
19
+ # image generation, code interpreter, remote MCP).
20
+ BUILT_IN_TOOL_ITEM_TYPES = %i[
21
+ web_search_call file_search_call image_generation_call code_interpreter_call mcp_call mcp_list_tools
22
+ ].freeze
23
+
24
+ # Prefixes of the progress events those tools stream.
25
+ BUILT_IN_TOOL_EVENT_PREFIXES = %w[
26
+ response.web_search_call. response.file_search_call. response.image_generation_call.
27
+ response.code_interpreter_call response.mcp_call response.mcp_list_tools.
28
+ ].freeze
29
+
18
30
  # @return [Class]
19
31
  def self.options_klass
20
32
  Options
@@ -99,6 +111,7 @@ module ActiveAgent
99
111
  # - `:"response.content_part.done"` - content part completed
100
112
  # - `:"response.output_item.done"` - message, reasoning or function call completed
101
113
  # - `:"response.completed"` - response finished
114
+ # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
102
115
  #
103
116
  # @param api_response_event [Hash] streaming chunk with :type key
104
117
  # @return [void]
@@ -106,6 +119,12 @@ module ActiveAgent
106
119
  def process_stream_chunk(api_response_event)
107
120
  instrument("stream_chunk.active_agent", chunk_type: api_response_event.type)
108
121
 
122
+ # Built-in tools report their progress with their own events; the
123
+ # finished tool call arrives as an output item.
124
+ if BUILT_IN_TOOL_EVENT_PREFIXES.any? { api_response_event.type.to_s.start_with?(_1) }
125
+ return
126
+ end
127
+
109
128
  case api_response_event.type
110
129
  # Response Created
111
130
  when :"response.created", :"response.in_progress"
@@ -124,12 +143,20 @@ module ActiveAgent
124
143
  message[:content] += api_response_event.delta
125
144
  broadcast_stream_update(message, api_response_event.delta)
126
145
 
146
+ # -> -> -> Content Text Citation (e.g. from web search)
147
+ when :"response.output_text.annotation.added"
148
+ # No-Op: The text is complete at response.output_text.done
149
+
127
150
  # -> -> -> Content Text Completed [Full Text]
128
151
  when :"response.output_text.done"
129
152
  message = message_stack.find { _1[:id] == api_response_event.item_id }
130
153
  message[:content] = api_response_event.text
131
154
  broadcast_stream_update(message, nil) # Don't double send content
132
155
 
156
+ # -> -> -> Refusal (the response still ends with response.completed)
157
+ when :"response.refusal.delta", :"response.refusal.done"
158
+ # No-Op: The message has no refusal field to stream it into
159
+
133
160
  # -> -> -> Content Function Call Append
134
161
  when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
135
162
  # No-Op: Wait for FC to Land
@@ -147,8 +174,16 @@ module ActiveAgent
147
174
  when :"response.output_item.done"
148
175
  process_stream_output_item_done(api_response_event)
149
176
 
150
- # Response Completed
151
- when :"response.completed"
177
+ # Response Completed, or cut short (e.g. by max_output_tokens) with
178
+ # the output it has so far
179
+ when :"response.completed", :"response.incomplete"
180
+ # The streamed response carries no body for resolve_prompt to read
181
+ # usage from, so it is recorded from the final event.
182
+ record_stream_usage(api_response_event.response.usage)
183
+
184
+ response = api_response_event.response
185
+ self.stream_finish_reason = (response.incomplete_details&.reason || response.status).to_s
186
+
152
187
  # Completion, tool loop included, runs from stream_finished! once
153
188
  # the stream has drained, so each turn's tool calls run once.
154
189
  self.stream_completion_pending = true
@@ -174,7 +209,7 @@ module ActiveAgent
174
209
  # PATCH: API returns an empty array instead of empty string due to a bug in their serialization
175
210
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
176
211
  message_stack << { content: "" }.merge(item_hash)
177
- when :function_call, :reasoning
212
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
178
213
  # No-Op: Wait for the item to land (-> response.output_item.done)
179
214
  else
180
215
  raise "Unexpected Item Type: #{api_response_event.item.type}"
@@ -194,7 +229,8 @@ module ActiveAgent
194
229
  case api_response_event.item.type
195
230
  when :message
196
231
  # No-Op: Message Up to Date
197
- when :function_call, :reasoning
232
+ when :function_call, :reasoning, *BUILT_IN_TOOL_ITEM_TYPES
233
+ # Kept like the non-streamed output, which holds every item
198
234
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
199
235
  message_stack << item_hash
200
236
  else
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/chat/_types"
4
+ require_relative "options"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module Opper
9
+ # ActiveModel type for casting and serializing Opper requests.
10
+ #
11
+ # Opper is OpenAI-compatible, so requests use the same shape as the
12
+ # OpenAI Chat API. This delegates entirely to OpenAI::Chat::RequestType.
13
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Opper
8
+ # Configuration options for the Opper provider.
9
+ #
10
+ # Extends OpenAI::Options, overriding the base URL to point at Opper's
11
+ # OpenAI-compatible gateway and resolving the API key from OPPER_API_KEY.
12
+ # Opper does not use organization or project identifiers.
13
+ #
14
+ # @example Basic configuration
15
+ # options = Options.new(api_key: ENV["OPPER_API_KEY"])
16
+ #
17
+ # @see https://docs.opper.ai
18
+ # @see https://platform.opper.ai Opper API Keys
19
+ class Options < ActiveAgent::Providers::OpenAI::Options
20
+ # @!attribute base_url
21
+ # @return [String] API endpoint (default: "https://api.opper.ai/v3/compat")
22
+ attribute :base_url, :string, as: "https://api.opper.ai/v3/compat"
23
+
24
+ private
25
+
26
+ def resolve_api_key(kwargs)
27
+ kwargs[:api_key] ||
28
+ kwargs[:access_token] ||
29
+ ENV["OPPER_API_KEY"]
30
+ end
31
+
32
+ # Not used as part of Opper
33
+ def resolve_organization_id(kwargs) = nil
34
+ def resolve_project_id(kwargs) = nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "_base_provider"
2
+
3
+ require_gem!(:openai, __FILE__)
4
+
5
+ require_relative "open_ai_provider"
6
+ require_relative "opper/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Opper's OpenAI-compatible LLM gateway.
11
+ #
12
+ # Extends the OpenAI provider to work with Opper's OpenAI-compatible API,
13
+ # enabling access to models from many providers through a single
14
+ # interface. Model ids are bare pool names (e.g. +claude-sonnet-4-6+);
15
+ # a +provider/model+ id (e.g. +anthropic/claude-sonnet-4-6+) pins a route.
16
+ #
17
+ # Opper is a plain OpenAI-compatible gateway: requests, responses and
18
+ # transforms are identical to the OpenAI Chat API, so this provider reuses
19
+ # OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly. The only
20
+ # Opper-specific configuration is the base URL and API key, which live in
21
+ # Opper::Options.
22
+ #
23
+ # @example Configuration in active_agent.yml
24
+ # opper:
25
+ # service: "Opper"
26
+ # api_key: <%= ENV["OPPER_API_KEY"] %>
27
+ # model: "claude-sonnet-4-6"
28
+ #
29
+ # @see OpenAI::ChatProvider
30
+ # @see https://docs.opper.ai
31
+ class OpperProvider < OpenAI::ChatProvider
32
+ # @return [String]
33
+ def self.service_name
34
+ "Opper"
35
+ end
36
+
37
+ # @return [Class]
38
+ def self.options_klass
39
+ Opper::Options
40
+ end
41
+
42
+ # @return [ActiveModel::Type::Value]
43
+ def self.prompt_request_type
44
+ OpenAI::Chat::RequestType.new
45
+ end
46
+
47
+ # @return [ActiveModel::Type::Value]
48
+ def self.embed_request_type
49
+ OpenAI::Embedding::RequestType.new
50
+ end
51
+
52
+ protected
53
+
54
+ # Executes an embedding request via Opper's OpenAI-compatible API.
55
+ #
56
+ # @param parameters [Hash]
57
+ # @return [Hash] symbolized API response
58
+ def api_embed_execute(parameters)
59
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
60
+ end
61
+
62
+ # @see BaseProvider#api_response_normalize
63
+ # @param api_response [OpenAI::Models::ChatCompletion]
64
+ # @return [Hash] normalized response hash
65
+ def api_response_normalize(api_response)
66
+ return api_response unless api_response
67
+
68
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -34,6 +34,21 @@ module ActiveAgent
34
34
  #
35
35
  # ContactForm.to_json_schema
36
36
  #
37
+ # @example ActiveModel with a nested array of objects
38
+ # class Person
39
+ # include ActiveModel::Model
40
+ # include ActiveModel::Attributes
41
+ # include ActiveAgent::SchemaGenerator
42
+ #
43
+ # attribute :name, :string
44
+ # has_many :addresses, class_name: "Address"
45
+ # end
46
+ #
47
+ # Person.to_json_schema
48
+ # # => { name: "person", schema: { type: "object", properties: {
49
+ # # name: {...}, addresses: { type: "array", items: { "$ref": "#/$defs/address" } } },
50
+ # # ..., "$defs": { address: { type: "object", ... } } } }
51
+ #
37
52
  # @see Builder
38
53
  module SchemaGenerator
39
54
  extend ActiveSupport::Concern
@@ -47,6 +62,13 @@ module ActiveAgent
47
62
  # Fallback for any class that includes this module
48
63
  extend ActiveModelClassMethods
49
64
  end
65
+
66
+ # The railtie includes this module into ActiveRecord::Base too, whose
67
+ # has_many must stay ActiveRecord's.
68
+ unless respond_to?(:has_many)
69
+ extend ActiveModelAssociations
70
+ class_attribute :schema_associations, instance_accessor: false, default: {}.freeze
71
+ end
50
72
  end
51
73
 
52
74
  # Class methods added to ActiveRecord models.
@@ -106,6 +128,40 @@ module ActiveAgent
106
128
  end
107
129
  end
108
130
 
131
+ # Nested schemas for ActiveModel classes, which have no associations of
132
+ # their own. Added only to a class without a +has_many+ already.
133
+ module ActiveModelAssociations
134
+ # Declares an array of objects described by another schema class.
135
+ #
136
+ # {ActiveModelClassMethods#to_json_schema} emits the attribute as an
137
+ # array whose items are a +$ref+ to that class's schema, which goes under
138
+ # the root +$defs+ keyed by the singular name in lower camel case
139
+ # (+addresses+ becomes +address+, +line_items+ becomes +lineItem+).
140
+ # The prompt preserves both property and definition names.
141
+ #
142
+ # Also declares the attribute, defaulting to an empty array, so the
143
+ # model accepts the data the schema describes.
144
+ #
145
+ # @param name [Symbol] the attribute holding the array
146
+ # @param class_name [String] full constant name of the item class, which
147
+ # generates JSON Schema too; resolved when the schema is generated
148
+ # @return [void]
149
+ # @raise [ArgumentError] when the class does not include
150
+ # +ActiveModel::Attributes+, which declares the attribute
151
+ #
152
+ # @example
153
+ # has_many :addresses, class_name: "Address"
154
+ def has_many(name, class_name:)
155
+ unless respond_to?(:attribute)
156
+ raise ArgumentError, "#{self.name || inspect}.has_many :#{name} needs ActiveModel::Attributes to declare the attribute; include it before calling has_many"
157
+ end
158
+
159
+ self.schema_associations = schema_associations.merge(name.to_sym => class_name).freeze
160
+
161
+ attribute name, default: -> { [] }
162
+ end
163
+ end
164
+
109
165
  # Internal builder class for constructing JSON Schemas from model classes.
110
166
  #
111
167
  # This class handles the actual schema generation logic, supporting both
@@ -122,24 +178,7 @@ module ActiveAgent
122
178
  #
123
179
  # @raise [ArgumentError] If model_class is not an ActiveRecord or ActiveModel class
124
180
  def self.json_schema_from_model(model_class, options = {})
125
- schema = {
126
- type: "object",
127
- properties: {},
128
- required: [],
129
- additional_properties: options.fetch(:additional_properties, false)
130
- }
131
-
132
- if defined?(ActiveRecord::Base) && model_class < ActiveRecord::Base
133
- schema = build_activerecord_schema(schema, model_class, options)
134
- elsif defined?(ActiveModel::Model) && model_class.include?(ActiveModel::Model)
135
- schema = build_activemodel_schema(schema, model_class, options)
136
- else
137
- raise ArgumentError, "Model must be an ActiveRecord or ActiveModel class"
138
- end
139
-
140
- # OpenAI strict mode requires all properties to be in the required array
141
- # So we add all properties to required if strict mode is enabled
142
- schema[:required] = schema[:properties].keys.sort if options[:strict]
181
+ schema = object_schema(model_class, options)
143
182
 
144
183
  {
145
184
  name: options[:name] || model_class.name.underscore,
@@ -151,6 +190,37 @@ module ActiveAgent
151
190
  class << self
152
191
  private
153
192
 
193
+ # Builds the object schema of a model class, without the name and
194
+ # strict flag {json_schema_from_model} wraps it in.
195
+ #
196
+ # @param model_class [Class] The ActiveRecord or ActiveModel class
197
+ # @param options [Hash] Schema generation options
198
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
199
+ #
200
+ # @return [Hash] The object schema
201
+ def object_schema(model_class, options, ancestors = [])
202
+ schema = {
203
+ type: "object",
204
+ properties: {},
205
+ required: [],
206
+ additional_properties: options.fetch(:additional_properties, false)
207
+ }
208
+
209
+ if defined?(ActiveRecord::Base) && model_class < ActiveRecord::Base
210
+ schema = build_activerecord_schema(schema, model_class, options)
211
+ elsif defined?(ActiveModel::Model) && model_class.include?(ActiveModel::Model)
212
+ schema = build_activemodel_schema(schema, model_class, options, ancestors)
213
+ else
214
+ raise ArgumentError, "Model must be an ActiveRecord or ActiveModel class"
215
+ end
216
+
217
+ # OpenAI strict mode requires all properties to be in the required array
218
+ # So we add all properties to required if strict mode is enabled
219
+ schema[:required] = schema[:properties].keys.sort if options[:strict]
220
+
221
+ schema
222
+ end
223
+
154
224
  # Builds a JSON Schema from an ActiveRecord model.
155
225
  #
156
226
  # Extracts column information, associations, and validations to build
@@ -187,14 +257,16 @@ module ActiveAgent
187
257
 
188
258
  # Builds a JSON Schema from an ActiveModel class.
189
259
  #
190
- # Extracts attribute types and validations to build a schema representation.
260
+ # Extracts attribute types, nested schemas and validations to build a
261
+ # schema representation.
191
262
  #
192
263
  # @param schema [Hash] The schema hash to populate
193
264
  # @param model_class [Class] The ActiveModel class
194
265
  # @param options [Hash] Schema generation options
266
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
195
267
  #
196
268
  # @return [void]
197
- def build_activemodel_schema(schema, model_class, options)
269
+ def build_activemodel_schema(schema, model_class, options, ancestors)
198
270
  if model_class.respond_to?(:attribute_types)
199
271
  model_class.attribute_types.each do |name, type|
200
272
  next if options[:exclude]&.include?(name.to_sym)
@@ -206,6 +278,10 @@ module ActiveAgent
206
278
  raise ArgumentError, "#{model_class.name} does not define any attributes. Use `attribute :name, :type` to define attributes."
207
279
  end
208
280
 
281
+ if model_class.respond_to?(:schema_associations)
282
+ schema = add_activemodel_associations_to_schema(schema, model_class, options, ancestors)
283
+ end
284
+
209
285
  if model_class.respond_to?(:validators)
210
286
  schema = add_validations_to_schema(schema, model_class, options)
211
287
  end
@@ -356,6 +432,51 @@ module ActiveAgent
356
432
  schema
357
433
  end
358
434
 
435
+ # Adds the arrays an ActiveModel class declares with
436
+ # {ActiveModelAssociations#has_many}, each item a +$ref+ to the nested
437
+ # class's schema under +$defs+.
438
+ #
439
+ # A +$ref+ resolves against the root of the schema, so the definitions
440
+ # a nested class collects move up into this schema's +$defs+.
441
+ #
442
+ # @param schema [Hash] The schema hash to populate
443
+ # @param model_class [Class] The ActiveModel class
444
+ # @param options [Hash] Schema generation options
445
+ # @param ancestors [Array<Class>] the classes nesting this one, outermost first
446
+ #
447
+ # @return [Hash]
448
+ # @raise [ArgumentError] when a class nests itself, or two different
449
+ # schemas would share a definition name
450
+ def add_activemodel_associations_to_schema(schema, model_class, options, ancestors)
451
+ path = ancestors + [ model_class ]
452
+ definitions = {}
453
+
454
+ model_class.schema_associations.each do |name, class_name|
455
+ next if options[:exclude]&.include?(name)
456
+
457
+ nested_class = class_name.constantize
458
+ if path.include?(nested_class)
459
+ raise ArgumentError, "#{model_class.name}.has_many :#{name} is recursive (#{(path + [ nested_class ]).map(&:name).join(' > ')}); recursive schemas are not supported"
460
+ end
461
+
462
+ key = name.to_s.singularize.camelize(:lower).to_sym
463
+ nested = object_schema(nested_class, options.slice(:strict, :additional_properties), path)
464
+
465
+ [ [ key, nested ], *nested.delete(:$defs) ].each do |definition_key, definition|
466
+ if definitions.key?(definition_key) && definitions[definition_key] != definition
467
+ raise ArgumentError, "#{model_class.name} nests two different schemas under the definition #{definition_key.inspect}"
468
+ end
469
+
470
+ definitions[definition_key] = definition
471
+ end
472
+
473
+ schema[:properties][name] = { type: "array", items: { "$ref": "#/$defs/#{key}" } }
474
+ end
475
+
476
+ schema[:$defs] = definitions if definitions.any?
477
+ schema
478
+ end
479
+
359
480
  # Adds validation constraints to the schema.
360
481
  #
361
482
  # Translates ActiveModel validations (presence, length, numericality,
@@ -375,11 +496,14 @@ module ActiveAgent
375
496
  when ActiveModel::Validations::PresenceValidator
376
497
  schema[:required] << attribute.to_sym unless schema[:required].include?(attribute.to_sym)
377
498
  when ActiveModel::Validations::LengthValidator
499
+ # An array, such as a has_many, is limited by its item count.
500
+ min_key, max_key = schema[:properties][attribute.to_sym][:type] == "array" ? [ :min_items, :max_items ] : [ :min_length, :max_length ]
501
+
378
502
  if validator.options[:minimum]
379
- schema[:properties][attribute.to_sym][:min_length] = validator.options[:minimum]
503
+ schema[:properties][attribute.to_sym][min_key] = validator.options[:minimum]
380
504
  end
381
505
  if validator.options[:maximum]
382
- schema[:properties][attribute.to_sym][:max_length] = validator.options[:maximum]
506
+ schema[:properties][attribute.to_sym][max_key] = validator.options[:maximum]
383
507
  end
384
508
  when ActiveModel::Validations::NumericalityValidator
385
509
  if validator.options[:greater_than]
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.9.1"
2
+ VERSION = "1.9.3"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -456,6 +456,7 @@ files:
456
456
  - lib/active_agent/deprecator.rb
457
457
  - lib/active_agent/evals.rb
458
458
  - lib/active_agent/evals/catalog.rb
459
+ - lib/active_agent/evals/comparison.rb
459
460
  - lib/active_agent/evals/correlation.rb
460
461
  - lib/active_agent/evals/design_tokens.rb
461
462
  - lib/active_agent/evals/diagnosis.rb
@@ -466,12 +467,14 @@ files:
466
467
  - lib/active_agent/evals/replay.rb
467
468
  - lib/active_agent/evals/report.rb
468
469
  - lib/active_agent/evals/report_html.rb
470
+ - lib/active_agent/evals/report_markdown.rb
469
471
  - lib/active_agent/evals/result.rb
470
472
  - lib/active_agent/evals/ruby_llm.rb
471
473
  - lib/active_agent/evals/runner.rb
472
474
  - lib/active_agent/evals/scenario.rb
473
475
  - lib/active_agent/evals/scenario_parser.rb
474
476
  - lib/active_agent/evals/scorer.rb
477
+ - lib/active_agent/evals/statistics.rb
475
478
  - lib/active_agent/evals/suite.rb
476
479
  - lib/active_agent/generation.rb
477
480
  - lib/active_agent/generation_job.rb
@@ -583,6 +586,9 @@ files:
583
586
  - lib/active_agent/providers/open_router_provider.rb
584
587
  - lib/active_agent/providers/openai_provider.rb
585
588
  - lib/active_agent/providers/openrouter_provider.rb
589
+ - lib/active_agent/providers/opper/_types.rb
590
+ - lib/active_agent/providers/opper/options.rb
591
+ - lib/active_agent/providers/opper_provider.rb
586
592
  - lib/active_agent/providers/requesty/_types.rb
587
593
  - lib/active_agent/providers/requesty/options.rb
588
594
  - lib/active_agent/providers/requesty_provider.rb