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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +407 -1
- data/README.md +3 -0
- data/lib/active_agent/base.rb +29 -4
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/publisher.rb +19 -12
- data/lib/active_agent/evals/report.rb +82 -66
- data/lib/active_agent/evals/report_html.rb +149 -12
- data/lib/active_agent/evals/report_markdown.rb +202 -0
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +3 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +4 -1
- data/lib/active_agent/providers/common/responses/base.rb +15 -2
- data/lib/active_agent/providers/common/usage.rb +4 -2
- data/lib/active_agent/providers/open_ai/chat_provider.rb +46 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +40 -4
- data/lib/active_agent/providers/opper/_types.rb +16 -0
- data/lib/active_agent/providers/opper/options.rb +38 -0
- data/lib/active_agent/providers/opper_provider.rb +72 -0
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +8 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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][
|
|
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][
|
|
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]
|
data/lib/active_agent/version.rb
CHANGED
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.
|
|
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-
|
|
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
|