activeagent 1.0.2 → 1.1.0

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +26 -0
  4. data/lib/active_agent/base.rb +4 -0
  5. data/lib/active_agent/concerns/view.rb +1 -1
  6. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +27 -6
  7. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +11 -1
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +15 -12
  9. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +27 -7
  10. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +9 -0
  11. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +1 -1
  12. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +18 -2
  13. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +15 -3
  14. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +3 -1
  15. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +4 -4
  16. data/lib/active_agent/dashboard/config/routes.rb +5 -64
  17. data/lib/active_agent/dashboard/engine.rb +19 -15
  18. data/lib/active_agent/dashboard.rb +13 -3
  19. data/lib/active_agent/model_capabilities.rb +89 -0
  20. data/lib/active_agent/providers/_base_provider.rb +23 -2
  21. data/lib/active_agent/providers/anthropic/request.rb +3 -1
  22. data/lib/active_agent/providers/anthropic/transforms.rb +88 -0
  23. data/lib/active_agent/providers/bedrock_provider.rb +2 -2
  24. data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
  25. data/lib/active_agent/providers/errors.rb +140 -0
  26. data/lib/active_agent/providers/mock/request.rb +1 -4
  27. data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
  28. data/lib/active_agent/providers/open_ai/chat_provider.rb +3 -3
  29. data/lib/active_agent/providers/requesty/_types.rb +16 -0
  30. data/lib/active_agent/providers/requesty/options.rb +39 -0
  31. data/lib/active_agent/providers/requesty_provider.rb +63 -0
  32. data/lib/active_agent/railtie.rb +5 -0
  33. data/lib/active_agent/telemetry/configuration.rb +112 -172
  34. data/lib/active_agent/telemetry/instrumentation.rb +137 -14
  35. data/lib/active_agent/telemetry/reporter.rb +12 -165
  36. data/lib/active_agent/telemetry/span.rb +18 -245
  37. data/lib/active_agent/telemetry/tracer.rb +67 -70
  38. data/lib/active_agent/telemetry.rb +1 -2
  39. data/lib/active_agent/version.rb +1 -1
  40. data/lib/active_agent.rb +5 -0
  41. data/lib/generators/active_agent/dashboard/install_generator.rb +30 -2
  42. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +41 -4
  43. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +20 -4
  44. metadata +39 -16
  45. data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
  46. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
  47. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
  48. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
  49. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
  50. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
  51. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
  52. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
  53. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
  54. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ # Per-model capability quirks, applied before a request reaches the
5
+ # provider. Vendors ship models that reject otherwise-standard sampling
6
+ # parameters (thinking-first models steered by prompting/effort instead)
7
+ # with an API 400 — this registry strips those parameters up front so an
8
+ # agent configured with a shared temperature keeps working across model
9
+ # switches.
10
+ #
11
+ # The built-in rules cover the known families; apps can extend the
12
+ # registry for new or self-hosted models:
13
+ #
14
+ # @example Register a custom rule
15
+ # ActiveAgent::ModelCapabilities.register(/\Amy-reasoning-model/, unsupported: [:temperature, :top_p])
16
+ #
17
+ # @example Disable sanitization entirely
18
+ # ActiveAgent::ModelCapabilities.enabled = false
19
+ module ModelCapabilities
20
+ SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
21
+
22
+ # Model families that reject sampling parameters with a 400:
23
+ # - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
24
+ # Fable 5 / Mythos 5)
25
+ # - OpenAI reasoning models (o-series, GPT-5 family)
26
+ BUILTIN_RULES = [
27
+ { pattern: /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/, unsupported: SAMPLING_PARAMS },
28
+ { pattern: /\A(o1|o3|o4)(-|$)/, unsupported: SAMPLING_PARAMS },
29
+ { pattern: /\Agpt-5/, unsupported: SAMPLING_PARAMS }
30
+ ].freeze
31
+
32
+ class << self
33
+ # Master switch; on by default. Set false to send parameters through
34
+ # untouched (the vendor then enforces its own rules).
35
+ attr_writer :enabled
36
+
37
+ def enabled
38
+ return @enabled unless @enabled.nil?
39
+
40
+ true
41
+ end
42
+
43
+ # Registers an app-defined capability rule ahead of the built-ins.
44
+ #
45
+ # @param pattern [Regexp] matched against the model name
46
+ # @param unsupported [Array<Symbol>] parameter keys the model rejects
47
+ def register(pattern, unsupported:)
48
+ custom_rules << { pattern: pattern, unsupported: unsupported.map(&:to_sym) }
49
+ end
50
+
51
+ def custom_rules
52
+ @custom_rules ||= []
53
+ end
54
+
55
+ def reset!
56
+ @custom_rules = []
57
+ @enabled = nil
58
+ end
59
+
60
+ # @return [Array<Symbol>] parameter keys the model rejects
61
+ def unsupported_params(model)
62
+ return [] if model.nil?
63
+
64
+ (custom_rules + BUILTIN_RULES).each do |rule|
65
+ return rule[:unsupported] if model.to_s.match?(rule[:pattern])
66
+ end
67
+ []
68
+ end
69
+
70
+ def sampling_supported?(model)
71
+ (unsupported_params(model) & SAMPLING_PARAMS).empty?
72
+ end
73
+
74
+ # Strips parameters the model rejects, in place. Returns the removed
75
+ # keys (empty when nothing applied).
76
+ #
77
+ # @param parameters [Hash] prepared prompt parameters (must carry :model)
78
+ # @return [Array<Symbol>] removed parameter keys
79
+ def sanitize!(parameters)
80
+ return [] unless enabled
81
+ return [] unless parameters.is_a?(Hash)
82
+
83
+ removed = unsupported_params(parameters[:model]).select { |key| parameters.key?(key) }
84
+ removed.each { |key| parameters.delete(key) }
85
+ removed
86
+ end
87
+ end
88
+ end
89
+ end
@@ -55,7 +55,13 @@ module ActiveAgent
55
55
  :request, :message_stack, # Runtime
56
56
  :stream_broadcaster, :streaming, # Callback (Streams)
57
57
  :tools_function, # Callback (Tools)
58
- :usage_stack # Usage Tracking
58
+ :usage_stack, # Usage Tracking
59
+ :max_tool_turns, :tool_turns # Tool-loop safety
60
+
61
+ # Upper bound on tool-calling round-trips within one generation. A
62
+ # model that keeps emitting tool calls otherwise recurses until the
63
+ # provider stops it — override per agent/prompt with max_tool_turns:.
64
+ DEFAULT_MAX_TOOL_TURNS = 25
59
65
 
60
66
  # @return [String] e.g., "Anthropic", "OpenAI"
61
67
  def self.service_name
@@ -106,6 +112,8 @@ module ActiveAgent
106
112
  self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
107
113
  self.streaming = false
108
114
  self.tools_function = kwargs.delete(:tools_function)
115
+ self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
116
+ self.tool_turns = 0
109
117
  self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
110
118
  self.context = kwargs
111
119
  self.message_stack = []
@@ -344,7 +352,7 @@ module ActiveAgent
344
352
  message_stack.push(*api_messages)
345
353
  end
346
354
 
347
- if (tool_calls = process_prompt_finished_extract_function_calls)&.any?
355
+ if (tool_calls = process_prompt_finished_extract_function_calls)&.any? && tool_turn_allowed?
348
356
  process_function_calls(tool_calls)
349
357
  resolve_prompt
350
358
  else
@@ -373,6 +381,19 @@ module ActiveAgent
373
381
  end
374
382
  end
375
383
 
384
+ # Counts a tool round-trip against the per-generation cap. When the
385
+ # cap is hit the loop finishes cleanly with the messages gathered so
386
+ # far (a partial result) instead of recursing indefinitely.
387
+ #
388
+ # @return [Boolean] whether another tool round-trip may run
389
+ def tool_turn_allowed?
390
+ self.tool_turns += 1
391
+ return true if max_tool_turns.nil? || tool_turns <= max_tool_turns
392
+
393
+ instrument("tool_turns_exceeded.active_agent", limit: max_tool_turns)
394
+ false
395
+ end
396
+
376
397
  # @abstract
377
398
  # @param api_response [Object]
378
399
  # @return [Array<Message>, nil]
@@ -71,7 +71,9 @@ module ActiveAgent
71
71
  # @raise [ArgumentError] when gem model validation fails
72
72
  def initialize(**params)
73
73
  # Step 1: Extract custom fields that gem doesn't support
74
- @response_format = params.delete(:response_format)
74
+ # Read response_format without deleting - normalize_params will delete and convert it
75
+ # to output_config for json_schema, or drop it for other types.
76
+ @response_format = params[:response_format]
75
77
  @stream = params.delete(:stream)
76
78
  anthropic_beta = params.delete(:anthropic_beta)
77
79
 
@@ -31,6 +31,12 @@ module ActiveAgent
31
31
  params[:tools] = normalize_tools(params[:tools]) if params[:tools]
32
32
  params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
33
33
 
34
+ # Normalize json_schema response_format → output_config (Anthropic's native structured output field)
35
+ if params[:response_format]
36
+ output_config = normalize_response_format(params.delete(:response_format))
37
+ params[:output_config] = output_config if output_config
38
+ end
39
+
34
40
  # Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
35
41
  if params[:mcps]
36
42
  params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps))
@@ -159,6 +165,87 @@ module ActiveAgent
159
165
  end
160
166
  end
161
167
 
168
+ # Normalizes response_format to Anthropic output_config structure.
169
+ #
170
+ # Supported (ActiveAgent common format):
171
+ # { type: "json_schema", json_schema: { name: "...", schema: {...}, strict: true } }
172
+ # → { format: { type: "json_schema", schema: {...} } }
173
+ #
174
+ # Notes:
175
+ # - Anthropic requires `additionalProperties: false` on all object schemas.
176
+ # This is auto-injected into any object schemas that don't have it set.
177
+ # - Anthropic does not use OpenAI's `name` or `strict` fields in output_config.format.
178
+ # - json_object is not handled here; it remains prompt-emulated.
179
+ # - text is not handled here; Anthropic returns plain text by default.
180
+ #
181
+ # @param format [Hash, Symbol, String] ActiveAgent common response_format
182
+ # @return [Hash] Anthropic output_config hash, or nil if not applicable
183
+ def normalize_response_format(format)
184
+ case format
185
+ when Hash
186
+ format_hash = format.deep_symbolize_keys
187
+
188
+ if format_hash[:type].to_s == "json_schema"
189
+ schema = format_hash[:json_schema]&.dig(:schema)
190
+ if schema
191
+ schema = inject_additional_properties(schema.deep_dup)
192
+ end
193
+ {
194
+ format: {
195
+ type: "json_schema",
196
+ schema: schema
197
+ }.compact
198
+ }
199
+ elsif format_hash[:type].to_s == "json_object"
200
+ # json_object is not handled here; it remains prompt-emulated.
201
+ nil
202
+ elsif format_hash[:type].to_s == "text"
203
+ # text is not handled here; it remains prompt-emulated.
204
+ nil
205
+ else
206
+ # Pass through (already properly structured or Anthropic native format)
207
+ format_hash
208
+ end
209
+ when Symbol, String
210
+ # Bare :json_schema without a schema cannot use native output_config.
211
+ # Anthropic requires a valid JSON Schema — return nil to fall back.
212
+ nil
213
+ else
214
+ format
215
+ end
216
+ end
217
+
218
+ # Recursively injects `additionalProperties: false` into all object schemas.
219
+ #
220
+ # Anthropic's structured output API requires this field on all object types.
221
+ # Only inserts when not already explicitly set by the user.
222
+ #
223
+ # @param schema [Hash] JSON Schema hash
224
+ # @return [Hash] schema with additionalProperties injected
225
+ def inject_additional_properties(schema)
226
+ return schema unless schema.is_a?(Hash)
227
+
228
+ if schema[:type] == "object" && !schema.key?(:additionalProperties)
229
+ schema[:additionalProperties] = false
230
+ end
231
+
232
+ # Recurse into nested schemas
233
+ schema[:properties]&.each_value { |prop| inject_additional_properties(prop) }
234
+ schema[:items]&.then { |items| inject_additional_properties(items) }
235
+ schema[:anyOf]&.each { |s| inject_additional_properties(s) }
236
+ schema[:oneOf]&.each { |s| inject_additional_properties(s) }
237
+ schema[:allOf]&.each { |s| inject_additional_properties(s) }
238
+
239
+ if schema[:definitions]
240
+ schema[:definitions].each_value { |defn| inject_additional_properties(defn) }
241
+ end
242
+ if schema[:"$defs"]
243
+ schema[:"$defs"].each_value { |defn| inject_additional_properties(defn) }
244
+ end
245
+
246
+ schema
247
+ end
248
+
162
249
  # Merges consecutive same-role messages into single messages with multiple content blocks.
163
250
  #
164
251
  # Required by Anthropic API - consecutive messages with the same role must be combined.
@@ -437,6 +524,7 @@ module ActiveAgent
437
524
  msg.delete(:model)
438
525
  msg.delete(:stop_reason)
439
526
  msg.delete(:stop_sequence)
527
+ msg.delete(:stop_details)
440
528
  msg.delete(:type)
441
529
  msg.delete(:usage)
442
530
  end
@@ -15,11 +15,11 @@ module ActiveAgent
15
15
  # bedrock:
16
16
  # service: "Bedrock"
17
17
  # aws_region: "eu-west-2"
18
- # model: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
18
+ # model: "eu.anthropic.claude-sonnet-5-v1:0"
19
19
  #
20
20
  # @example Agent usage
21
21
  # class SummaryAgent < ApplicationAgent
22
- # generate_with :bedrock, model: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
22
+ # generate_with :bedrock, model: "eu.anthropic.claude-sonnet-5-v1:0"
23
23
  #
24
24
  # def summarize
25
25
  # prompt(message: params[:message])
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+
3
5
  module ActiveAgent
4
6
  module Providers
5
7
  # Provides exception handling for provider operations.
@@ -54,7 +56,16 @@ module ActiveAgent
54
56
  def with_exception_handling(&block)
55
57
  yield
56
58
  rescue => exception
57
- rescue_with_handler(exception) || raise
59
+ # Vendor API failures are normalized into the framework taxonomy
60
+ # (Errors::RateLimited, Errors::ContextLengthExceeded, ...) so
61
+ # rescue_from policy is portable across providers; the original
62
+ # exception is preserved as #cause. Anything unrecognizable —
63
+ # including ordinary Ruby errors — passes through untouched.
64
+ exception = Errors::Taxonomy.normalize(
65
+ exception,
66
+ provider_tag: (tag_name if respond_to?(:tag_name))
67
+ )
68
+ rescue_with_handler(exception) || raise(exception)
58
69
  nil # Discard handler return value to prevent polluting raw_response
59
70
  end
60
71
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Providers
5
+ # Typed provider failures, normalized across vendor SDKs.
6
+ #
7
+ # Every vendor raises its own exception classes for the same underlying
8
+ # conditions (rate limits, context overflows, content filters, outages),
9
+ # which makes retry/backoff/fallback policy impossible to express
10
+ # portably. The taxonomy classifies vendor errors into a small set of
11
+ # framework types — the original exception is preserved as +#cause+, so
12
+ # nothing is lost.
13
+ #
14
+ # @example Portable retry policy
15
+ # rescue_from ActiveAgent::Providers::Errors::RateLimited do |error|
16
+ # retry_job wait: 30.seconds
17
+ # end
18
+ #
19
+ # @example Fallback on outage
20
+ # rescue_from ActiveAgent::Providers::Errors::ServiceUnavailable do |error|
21
+ # FallbackAgent.with(params).ask.generate_later
22
+ # end
23
+ module Errors
24
+ # Base class for normalized provider failures.
25
+ class ProviderError < StandardError
26
+ # @return [Integer, nil] HTTP status from the vendor error, when known
27
+ attr_reader :status
28
+
29
+ # @return [String, nil] provider tag (e.g. "Anthropic", "OpenAI::Chat")
30
+ attr_reader :provider_tag
31
+
32
+ def initialize(message = nil, status: nil, provider_tag: nil)
33
+ super(message)
34
+ @status = status
35
+ @provider_tag = provider_tag
36
+ end
37
+ end
38
+
39
+ # 429s / vendor rate & quota limits. Retryable with backoff.
40
+ class RateLimited < ProviderError; end
41
+
42
+ # The prompt exceeded the model's context window. Not retryable
43
+ # without shrinking the input.
44
+ class ContextLengthExceeded < ProviderError; end
45
+
46
+ # Invalid, expired, or unauthorized credentials (401/403).
47
+ class AuthenticationFailed < ProviderError; end
48
+
49
+ # The vendor's safety layer refused the request or response.
50
+ class ContentFiltered < ProviderError; end
51
+
52
+ # Vendor-side failure or overload (5xx, timeouts, connection drops).
53
+ # Retryable; a natural trigger for provider fallback.
54
+ class ServiceUnavailable < ProviderError; end
55
+
56
+ # Malformed or unsupported request the vendor rejected (400/422)
57
+ # that doesn't classify more specifically.
58
+ class InvalidRequest < ProviderError; end
59
+
60
+ # Classifies vendor SDK exceptions into the taxonomy. Unrecognizable
61
+ # exceptions (including ordinary Ruby errors) pass through untouched —
62
+ # only errors that look like vendor API failures are normalized.
63
+ module Taxonomy
64
+ # Vendor SDK class names (demodulized) → taxonomy class. Covers the
65
+ # official anthropic/openai gems and SDKs following their naming.
66
+ NAME_MAP = {
67
+ "RateLimitError" => RateLimited,
68
+ "AuthenticationError" => AuthenticationFailed,
69
+ "PermissionDeniedError" => AuthenticationFailed,
70
+ "ContentFilterError" => ContentFiltered,
71
+ "InternalServerError" => ServiceUnavailable,
72
+ "APIConnectionError" => ServiceUnavailable,
73
+ "APIConnectionTimeoutError" => ServiceUnavailable,
74
+ "APITimeoutError" => ServiceUnavailable,
75
+ "OverloadedError" => ServiceUnavailable,
76
+ "ServiceUnavailableError" => ServiceUnavailable,
77
+ "BadRequestError" => InvalidRequest,
78
+ "UnprocessableEntityError" => InvalidRequest
79
+ }.freeze
80
+
81
+ STATUS_MAP = {
82
+ 400 => InvalidRequest,
83
+ 401 => AuthenticationFailed,
84
+ 403 => AuthenticationFailed,
85
+ 408 => ServiceUnavailable,
86
+ 422 => InvalidRequest,
87
+ 429 => RateLimited,
88
+ 529 => ServiceUnavailable # Anthropic "overloaded"
89
+ }.freeze
90
+
91
+ CONTEXT_LENGTH_PATTERN = /context length|context_length|maximum context|context window|too many tokens|prompt is too long|input (?:is )?too long/i
92
+ CONTENT_FILTER_PATTERN = /content (?:filter|policy|management)|filtered due to|blocked by|safety (?:system|filter)/i
93
+
94
+ class << self
95
+ # @param exception [Exception]
96
+ # @param provider_tag [String, nil]
97
+ # @return [Exception] a taxonomy error, or the original exception
98
+ # when it doesn't classify
99
+ def normalize(exception, provider_tag: nil)
100
+ return exception if exception.is_a?(ProviderError)
101
+
102
+ klass = classify(exception)
103
+ return exception unless klass
104
+
105
+ klass.new(exception.message, status: status_of(exception), provider_tag: provider_tag)
106
+ end
107
+
108
+ # @return [Class, nil]
109
+ def classify(exception)
110
+ name = exception.class.name.to_s.demodulize
111
+ status = status_of(exception)
112
+ api_error = NAME_MAP.key?(name) || !status.nil?
113
+ return nil unless api_error
114
+
115
+ message = exception.message.to_s
116
+ return ContextLengthExceeded if CONTEXT_LENGTH_PATTERN.match?(message)
117
+ return ContentFiltered if CONTENT_FILTER_PATTERN.match?(message)
118
+
119
+ NAME_MAP[name] || STATUS_MAP[status] || (status && status >= 500 ? ServiceUnavailable : nil)
120
+ end
121
+
122
+ # @return [Integer, nil]
123
+ def status_of(exception)
124
+ [ :status, :status_code, :http_status, :code ].each do |reader|
125
+ next unless exception.respond_to?(reader)
126
+
127
+ value = begin
128
+ exception.public_send(reader)
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ return value if value.is_a?(Integer)
133
+ end
134
+ nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -22,16 +22,13 @@ module ActiveAgent
22
22
  attribute :stream, :boolean, default: false
23
23
  attribute :tools # Array of tool definitions
24
24
  attribute :tool_choice # Tool choice configuration
25
+ attribute :response_format
25
26
 
26
27
  # Common Format Compatibility
27
28
  def message=(value)
28
29
  self.messages ||= []
29
30
  self.messages << value
30
31
  end
31
-
32
- def response_format
33
- { type: "text" }
34
- end
35
32
  end
36
33
  end
37
34
  end
@@ -55,12 +55,18 @@ module ActiveAgent
55
55
  OpenAI::Chat::Transforms.normalize_messages(messages)
56
56
  end
57
57
 
58
- # Normalizes instructions using OpenAI transforms
58
+ # Normalizes instructions using OpenAI transforms, then remaps the
59
+ # role: OpenAI's transforms emit the "developer" role, but the chat
60
+ # templates of Ollama-served models (qwen, llama, gemma, …) only
61
+ # know "system" — a "developer" message is silently dropped, so the
62
+ # model never sees its instructions.
59
63
  #
60
64
  # @param instructions [Array<String>, String]
61
- # @return [Array<OpenAI::Models::Chat::ChatCompletionMessageParam>]
65
+ # @return [Array<Hash>] system messages
62
66
  def normalize_instructions(instructions)
63
- OpenAI::Chat::Transforms.normalize_instructions(instructions)
67
+ OpenAI::Chat::Transforms.normalize_instructions(instructions).map do |message|
68
+ message.is_a?(Hash) ? message.merge(role: "system") : message
69
+ end
64
70
  end
65
71
 
66
72
  # Cleans up serialized request for API submission
@@ -104,7 +104,7 @@ module ActiveAgent
104
104
 
105
105
  # If we have a delta, we need to update a message in the stack
106
106
  message = find_or_create_message(api_message.index)
107
- message = message_merge_delta(message, api_message.delta.as_json.deep_symbolize_keys)
107
+ message_merge_delta(message, api_message.delta.deep_to_h)
108
108
 
109
109
  # Stream back content changes as they come in
110
110
  if api_message.delta.content
@@ -140,7 +140,7 @@ module ActiveAgent
140
140
  def process_function_calls(api_function_calls)
141
141
  api_function_calls.each do |api_function_call|
142
142
  content = instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
143
- case api_function_call[:type]
143
+ case api_function_call[:type].to_s
144
144
  when "function"
145
145
  process_tool_call_function(api_function_call[:function])
146
146
  else
@@ -245,7 +245,7 @@ module ActiveAgent
245
245
  end
246
246
  end
247
247
  when String
248
- hash[key] += value
248
+ hash[key] += value.to_s
249
249
  else
250
250
  hash[key] = value
251
251
  end
@@ -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 Requesty
9
+ # ActiveModel type for casting and serializing Requesty requests.
10
+ #
11
+ # Requesty 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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Requesty
8
+ # Configuration options for the Requesty provider.
9
+ #
10
+ # Extends OpenAI::Options, overriding the base URL to point at Requesty's
11
+ # OpenAI-compatible gateway and resolving the API key from REQUESTY_API_KEY.
12
+ # Requesty does not use organization or project identifiers.
13
+ #
14
+ # @example Basic configuration
15
+ # options = Options.new(api_key: ENV["REQUESTY_API_KEY"])
16
+ #
17
+ # @see https://docs.requesty.ai
18
+ # @see https://app.requesty.ai/api-keys Requesty API Keys
19
+ class Options < ActiveAgent::Providers::OpenAI::Options
20
+ # @!attribute base_url
21
+ # @return [String] API endpoint (default: "https://router.requesty.ai/v1")
22
+ attribute :base_url, :string, as: "https://router.requesty.ai/v1"
23
+
24
+ private
25
+
26
+ def resolve_api_key(kwargs)
27
+ kwargs[:api_key] ||
28
+ kwargs[:access_token] ||
29
+ ENV["REQUESTY_API_KEY"] ||
30
+ ENV["REQUESTY_ACCESS_TOKEN"]
31
+ end
32
+
33
+ # Not used as part of Requesty
34
+ def resolve_organization_id(kwargs) = nil
35
+ def resolve_project_id(kwargs) = nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,63 @@
1
+ require_relative "_base_provider"
2
+
3
+ require_gem!(:openai, __FILE__)
4
+
5
+ require_relative "open_ai_provider"
6
+ require_relative "requesty/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Requesty's OpenAI-compatible LLM gateway.
11
+ #
12
+ # Extends the OpenAI provider to work with Requesty's OpenAI-compatible API,
13
+ # enabling access to multiple AI models through a single interface using the
14
+ # +provider/model+ naming convention (e.g. +openai/gpt-4o-mini+).
15
+ #
16
+ # Requesty is a plain OpenAI-compatible gateway: requests, responses and
17
+ # transforms are identical to the OpenAI Chat API, so this provider reuses
18
+ # OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly. The only
19
+ # Requesty-specific configuration is the base URL and API key, which live in
20
+ # Requesty::Options.
21
+ #
22
+ # @example Configuration in active_agent.yml
23
+ # requesty:
24
+ # service: "Requesty"
25
+ # api_key: <%= ENV["REQUESTY_API_KEY"] %>
26
+ # model: "openai/gpt-4o-mini"
27
+ #
28
+ # @see OpenAI::ChatProvider
29
+ # @see https://docs.requesty.ai
30
+ class RequestyProvider < OpenAI::ChatProvider
31
+ # @return [String]
32
+ def self.service_name
33
+ "Requesty"
34
+ end
35
+
36
+ # @return [Class]
37
+ def self.options_klass
38
+ Requesty::Options
39
+ end
40
+
41
+ # @return [ActiveModel::Type::Value]
42
+ def self.prompt_request_type
43
+ OpenAI::Chat::RequestType.new
44
+ end
45
+
46
+ # @return [ActiveModel::Type::Value]
47
+ def self.embed_request_type
48
+ OpenAI::Embedding::RequestType.new
49
+ end
50
+
51
+ protected
52
+
53
+ # @see BaseProvider#api_response_normalize
54
+ # @param api_response [OpenAI::Models::ChatCompletion]
55
+ # @return [Hash] normalized response hash
56
+ def api_response_normalize(api_response)
57
+ return api_response unless api_response
58
+
59
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -67,6 +67,11 @@ module ActiveAgent
67
67
  include ActiveAgent::Telemetry::Instrumentation
68
68
  instrument_telemetry!
69
69
  end
70
+
71
+ # Flush remaining traces when the process exits — without this,
72
+ # short-lived processes (rails runner, jobs, deploys rolling a
73
+ # server) drop whatever was buffered since the last interval flush.
74
+ at_exit { ActiveAgent::Telemetry.shutdown }
70
75
  end
71
76
  # endregion telemetry_configuration
72
77