activeagent 1.8.0 → 1.9.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1193 -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/diagnosis.rb +3 -3
  13. data/lib/active_agent/evals/format.rb +115 -0
  14. data/lib/active_agent/evals/judge.rb +2 -0
  15. data/lib/active_agent/evals/report.rb +232 -31
  16. data/lib/active_agent/evals/report_html.rb +316 -149
  17. data/lib/active_agent/evals/result.rb +40 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  19. data/lib/active_agent/evals/runner.rb +6 -2
  20. data/lib/active_agent/evals/scenario.rb +10 -3
  21. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  22. data/lib/active_agent/evals.rb +2 -0
  23. data/lib/active_agent/generation.rb +62 -2
  24. data/lib/active_agent/generation_job.rb +14 -3
  25. data/lib/active_agent/input_request/resume.rb +199 -0
  26. data/lib/active_agent/input_request.rb +279 -0
  27. data/lib/active_agent/providers/_base_provider.rb +153 -31
  28. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  29. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  30. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  31. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  32. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  33. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  34. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  35. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  36. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  37. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  38. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  39. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  41. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  42. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  43. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  44. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  45. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  46. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  47. data/lib/active_agent/providers/open_router/options.rb +6 -2
  48. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  49. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  50. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  51. data/lib/active_agent/schema_tools.rb +12 -0
  52. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  53. data/lib/active_agent/version.rb +1 -1
  54. data/lib/active_agent.rb +2 -0
  55. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  56. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  57. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  58. metadata +32 -2
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "active_support/core_ext/object/deep_dup"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ # Process-local cache for the tools an MCP server advertises.
9
+ #
10
+ # Asking a server what it offers is a round trip, and an expensive one: a
11
+ # hosted server measures ~450ms for `tools/list` on top of ~680ms for the
12
+ # handshake. The answer changes about as often as the server is redeployed,
13
+ # so a generation that pays that to relearn the same 27 tool names is paying
14
+ # it for nothing.
15
+ #
16
+ # With an entry cached, {MCPBridge} needs no connection at all to advertise
17
+ # the tools — it connects only if the model actually calls one. So the cost
18
+ # becomes zero for a generation that never reaches for an MCP tool, and one
19
+ # connection for a generation that does.
20
+ #
21
+ # Entries hold plain data: no sockets, no child processes, no class
22
+ # references. That is what makes it safe to hold across a fork — a child
23
+ # gets a copy-on-write snapshot with nothing in it bound to the parent's
24
+ # file descriptors or threads.
25
+ #
26
+ # @see ActiveAgent::Providers::MCPBridge
27
+ class MCPToolCache
28
+ # Seconds an entry stays usable. Long enough to cover a burst of
29
+ # generations, short enough that a redeployed server is picked up on its
30
+ # own without a restart.
31
+ DEFAULT_TTL = 300
32
+
33
+ # Upper bound on entries, oldest evicted first. Each entry is one server's
34
+ # tool schemas, so this is small; the cap exists to keep a process that
35
+ # talks to many distinct servers from growing without bound.
36
+ DEFAULT_MAX_ENTRIES = 100
37
+
38
+ @monitor = Monitor.new
39
+
40
+ class << self
41
+ # @return [Numeric] seconds an entry stays usable
42
+ def ttl = @ttl ||= DEFAULT_TTL
43
+
44
+ # @param seconds [Numeric] must be positive, or nothing would ever be
45
+ # reused
46
+ # @return [Numeric]
47
+ def ttl=(seconds)
48
+ unless seconds.is_a?(Numeric) && seconds.positive?
49
+ fail ArgumentError, "MCP tool cache ttl must be a positive number of seconds, got #{seconds.inspect}."
50
+ end
51
+
52
+ @ttl = seconds
53
+ end
54
+
55
+ # @return [Integer] entries kept before the oldest is evicted
56
+ def max_entries = @max_entries ||= DEFAULT_MAX_ENTRIES
57
+
58
+ # @param count [Integer]
59
+ # @return [Integer]
60
+ def max_entries=(count)
61
+ unless count.is_a?(Integer) && count.positive?
62
+ fail ArgumentError, "MCP tool cache max_entries must be a positive Integer, got #{count.inspect}."
63
+ end
64
+
65
+ @max_entries = count
66
+ end
67
+
68
+ # @return [Boolean] whether lookups consult the cache at all
69
+ def enabled? = @enabled.nil? ? true : @enabled
70
+
71
+ # @param value [Boolean]
72
+ # @return [Boolean]
73
+ def enabled=(value)
74
+ @enabled = !!value
75
+ end
76
+
77
+ # @param ttl [Numeric, nil]
78
+ # @param max_entries [Integer, nil]
79
+ # @param enabled [Boolean, nil]
80
+ # @return [Class] self, for chaining
81
+ def configure(ttl: nil, max_entries: nil, enabled: nil)
82
+ self.ttl = ttl if ttl
83
+ self.max_entries = max_entries if max_entries
84
+ self.enabled = enabled unless enabled.nil?
85
+
86
+ self
87
+ end
88
+
89
+ # The cached tools for +key+, computing and storing them on a miss.
90
+ #
91
+ # The block runs outside the lock: it performs network I/O, and holding
92
+ # the lock across it would serialise every generation in the process.
93
+ # Two threads racing on the same cold key both fetch and the later write
94
+ # wins — a duplicated round trip costs less than a global lock on the
95
+ # latency path.
96
+ #
97
+ # @param key [String] identifies the declaration, e.g. a digest
98
+ # @param enabled [Boolean, nil] per-generation override; nil uses the
99
+ # process-level setting
100
+ # @yieldreturn [Array<Hash>] the tools to cache when there is no entry
101
+ # @return [Array<Hash>] a copy the caller may mutate freely
102
+ def fetch(key, enabled: nil)
103
+ use_cache = enabled.nil? ? enabled? : enabled
104
+ return yield.deep_dup unless use_cache
105
+
106
+ cached = read(key)
107
+ return cached if cached
108
+
109
+ tools = yield
110
+ write(key, tools)
111
+ tools.deep_dup
112
+ end
113
+
114
+ # Drops one entry, so the next lookup asks the server again.
115
+ #
116
+ # @param key [String]
117
+ # @return [nil]
118
+ def invalidate(key)
119
+ monitor.synchronize { entries.delete(key) }
120
+
121
+ nil
122
+ end
123
+
124
+ # Drops every entry. For a redeploy-time hook or a test's setup.
125
+ #
126
+ # @return [nil]
127
+ def clear!
128
+ monitor.synchronize do
129
+ entries.clear
130
+ @hits = 0
131
+ @misses = 0
132
+ end
133
+
134
+ nil
135
+ end
136
+
137
+ # Empties the cache and restores the default settings.
138
+ #
139
+ # An application that configured the cache should not have to hand back
140
+ # every value to get a clean slate; a test suite benefits most, since
141
+ # the cache is process-global and outlives a single test.
142
+ #
143
+ # @return [nil]
144
+ def reset!
145
+ monitor.synchronize do
146
+ entries.clear
147
+ @hits = 0
148
+ @misses = 0
149
+ @ttl = nil
150
+ @max_entries = nil
151
+ @enabled = nil
152
+ end
153
+
154
+ nil
155
+ end
156
+
157
+ # @return [Hash] entry count and hit/miss counters, for a health check
158
+ # or to confirm the cache is doing what it should
159
+ def stats
160
+ monitor.synchronize { { size: entries.size, hits: @hits.to_i, misses: @misses.to_i } }
161
+ end
162
+
163
+ private
164
+
165
+ # @param key [String]
166
+ # @return [Array<Hash>, nil] a copy, or nil when absent or expired
167
+ def read(key)
168
+ monitor.synchronize do
169
+ entry = entries[key]
170
+
171
+ if entry.nil? || expired?(entry)
172
+ # A stale entry is dropped rather than left to expire twice.
173
+ entries.delete(key) if entry
174
+ @misses = @misses.to_i + 1
175
+
176
+ nil
177
+ else
178
+ @hits = @hits.to_i + 1
179
+
180
+ entry[:tools].deep_dup
181
+ end
182
+ end
183
+ end
184
+
185
+ # @param key [String]
186
+ # @param tools [Array<Hash>]
187
+ # @return [nil]
188
+ def write(key, tools)
189
+ monitor.synchronize do
190
+ # Re-inserting moves the key to the back, so eviction stays ordered
191
+ # by write time even when a key is refreshed.
192
+ entries.delete(key)
193
+ entries[key] = { tools: tools.deep_dup, stored_at: monotonic, expires_at: monotonic + ttl }
194
+
195
+ evict!
196
+ end
197
+
198
+ nil
199
+ end
200
+
201
+ # @param entry [Hash]
202
+ # @return [Boolean]
203
+ def expired?(entry) = entry[:expires_at] <= monotonic
204
+
205
+ # @return [void]
206
+ def evict!
207
+ entries.reject! { |_, entry| expired?(entry) }
208
+
209
+ # A Ruby Hash is insertion-ordered, so the front is the oldest write.
210
+ entries.shift while entries.size > max_entries
211
+ end
212
+
213
+ # @return [Hash] insertion-ordered
214
+ def entries = @entries ||= {}
215
+
216
+ # Reentrant, so a future caller can nest without deadlocking. Created with the class, so two first
217
+ # callers cannot each build their own.
218
+ #
219
+ # @return [Monitor]
220
+ def monitor = @monitor
221
+
222
+ # The monotonic clock, so an entry's lifetime is not affected by the
223
+ # wall clock moving.
224
+ #
225
+ # @return [Float]
226
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -50,7 +50,7 @@ module ActiveAgent
50
50
  kwargs = JSON.parse(api_function_call[:arguments], symbolize_names: true) if api_function_call[:arguments]
51
51
 
52
52
  instrument("tool_call.active_agent", tool_name: name) do
53
- tools_function.call(name, **kwargs)
53
+ call_tool_function(name, **kwargs)
54
54
  end
55
55
  end
56
56
  end
@@ -151,14 +151,15 @@ module ActiveAgent
151
151
  # Processes function/tool calls from the API response
152
152
  #
153
153
  # Executes each tool call and creates tool response messages
154
- # for the next iteration of the conversation.
154
+ # for the next iteration of the conversation. Pushes nothing when a
155
+ # tool asked the user for input.
155
156
  #
156
157
  # @param api_function_calls [Array<Hash>] function calls with :type, :id, and :function keys
157
158
  # @return [void]
158
159
  # @see Base#process_function_calls
159
160
  def process_function_calls(api_function_calls)
160
- api_function_calls.each do |api_function_call|
161
- content = instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
161
+ results = dispatch_tool_calls(api_function_calls) do |api_function_call|
162
+ instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
162
163
  case api_function_call[:type].to_s
163
164
  when "function"
164
165
  process_tool_call_function(api_function_call[:function])
@@ -166,7 +167,11 @@ module ActiveAgent
166
167
  fail "Unexpected Tool Call Type: #{api_function_call[:type]}"
167
168
  end
168
169
  end
170
+ end
171
+
172
+ return unless results
169
173
 
174
+ api_function_calls.zip(results).each do |api_function_call, content|
170
175
  # Create tool message using gem's message param class
171
176
  message = ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(
172
177
  role: "tool",
@@ -204,6 +209,20 @@ module ActiveAgent
204
209
  [ api_message ]
205
210
  end
206
211
 
212
+ # @see InputRequests#tool_call_reference
213
+ # @param api_function_call [Hash]
214
+ # @return [Array(String, String)]
215
+ def tool_call_reference(api_function_call)
216
+ [ api_function_call[:id].to_s, api_function_call.dig(:function, :name).to_s ]
217
+ end
218
+
219
+ # @see InputRequests#tool_call_arguments
220
+ # @param api_function_call [Hash]
221
+ # @return [Hash, String, nil]
222
+ def tool_call_arguments(api_function_call)
223
+ parse_tool_call_arguments(api_function_call.dig(:function, :arguments))
224
+ end
225
+
207
226
  # Extracts function calls from the last message in the stack.
208
227
  #
209
228
  # @return [Array<Hash>, nil] tool call objects or nil if no tool calls
@@ -64,7 +64,7 @@ module ActiveAgent
64
64
 
65
65
  def resolve_project_id(kwargs)
66
66
  kwargs[:project] ||
67
- kwargs[:project_id]
67
+ kwargs[:project_id] ||
68
68
  ENV["OPENAI_PROJECT_ID"] ||
69
69
  ENV["OPEN_AI_PROJECT_ID"]
70
70
  end
@@ -25,6 +25,13 @@ module ActiveAgent
25
25
  Responses::RequestType.new
26
26
  end
27
27
 
28
+ # The Responses API accepts remote MCP servers, so those are served by
29
+ # the provider as they always were. A local process cannot be handed to
30
+ # it, so a `command:` declaration runs client-side instead.
31
+ #
32
+ # @return [Array<Symbol>]
33
+ def mcp_native_transports = [ :url ]
34
+
28
35
  protected
29
36
 
30
37
  # @see BaseProvider#prepare_prompt_request
@@ -87,8 +94,10 @@ module ActiveAgent
87
94
  # - `:"response.output_text.done"` - complete text
88
95
  # - `:"response.function_call_arguments.delta"` - function argument updates
89
96
  # - `:"response.function_call_arguments.done"` - complete function arguments
97
+ # - `:"response.reasoning_summary_*"`, `:"response.reasoning_text.*"` -
98
+ # reasoning progress, kept from the reasoning item once it is done
90
99
  # - `:"response.content_part.done"` - content part completed
91
- # - `:"response.output_item.done"` - message or function call completed
100
+ # - `:"response.output_item.done"` - message, reasoning or function call completed
92
101
  # - `:"response.completed"` - response finished
93
102
  #
94
103
  # @param api_response_event [Hash] streaming chunk with :type key
@@ -125,6 +134,12 @@ module ActiveAgent
125
134
  when :"response.function_call_arguments.delta", :"response.function_call_arguments.done"
126
135
  # No-Op: Wait for FC to Land
127
136
 
137
+ # -> -> -> Reasoning Progress
138
+ when :"response.reasoning_summary_part.added", :"response.reasoning_summary_part.done",
139
+ :"response.reasoning_summary_text.delta", :"response.reasoning_summary_text.done",
140
+ :"response.reasoning_text.delta", :"response.reasoning_text.done"
141
+ # No-Op: Wait for the reasoning item to land
142
+
128
143
  # -> -> Content Part Completed [Full Part]
129
144
  when :"response.content_part.done"
130
145
 
@@ -134,8 +149,9 @@ module ActiveAgent
134
149
 
135
150
  # Response Completed
136
151
  when :"response.completed"
137
- # Once we are finished, close out and run tooling callbacks (Recursive)
138
- process_prompt_finished
152
+ # Completion, tool loop included, runs from stream_finished! once
153
+ # the stream has drained, so each turn's tool calls run once.
154
+ self.stream_completion_pending = true
139
155
  else
140
156
  raise "Unexpected Response Chunk Type: #{api_response_event.type}"
141
157
  end
@@ -143,8 +159,9 @@ module ActiveAgent
143
159
 
144
160
  # Processes output item added events from streaming response
145
161
  #
146
- # Handles message and function_call item types. For messages, adds to stack
147
- # with empty content. For function calls, waits for completion event.
162
+ # Handles message, reasoning and function_call item types. For messages,
163
+ # adds to stack with empty content. Reasoning items and function calls
164
+ # wait for their completion event.
148
165
  #
149
166
  # Required because API returns empty array instead of empty string for
150
167
  # initial message content due to serialization bug.
@@ -157,8 +174,8 @@ module ActiveAgent
157
174
  # PATCH: API returns an empty array instead of empty string due to a bug in their serialization
158
175
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item).compact_blank
159
176
  message_stack << { content: "" }.merge(item_hash)
160
- when :function_call
161
- # No-Op: Wait for FC to Land (-> response.output_item.done)
177
+ when :function_call, :reasoning
178
+ # No-Op: Wait for the item to land (-> response.output_item.done)
162
179
  else
163
180
  raise "Unexpected Item Type: #{api_response_event.item.type}"
164
181
  end
@@ -166,8 +183,10 @@ module ActiveAgent
166
183
 
167
184
  # Processes output item completion events from streaming response
168
185
  #
169
- # For function calls, adds completed item to message stack.
170
- # For messages, no action needed as content already updated via delta events.
186
+ # For function calls and reasoning items, adds the completed item to the
187
+ # message stack, so the next turn sends each reasoning item back with
188
+ # the function calls it led to. For messages, no action needed as
189
+ # content already updated via delta events.
171
190
  #
172
191
  # @param api_response_event [Hash] response chunk with completed :item
173
192
  # @return [void]
@@ -175,7 +194,7 @@ module ActiveAgent
175
194
  case api_response_event.item.type
176
195
  when :message
177
196
  # No-Op: Message Up to Date
178
- when :function_call
197
+ when :function_call, :reasoning
179
198
  item_hash = Responses::Transforms.gem_to_hash(api_response_event.item)
180
199
  message_stack << item_hash
181
200
  else
@@ -183,21 +202,25 @@ module ActiveAgent
183
202
  end
184
203
  end
185
204
 
186
- # Executes function calls and creates output messages for conversation continuation
205
+ # Executes function calls and creates output messages for conversation
206
+ # continuation. Pushes nothing when a call paused for the user.
187
207
  #
188
208
  # @param api_function_calls [Array<Hash>] function calls with :call_id and :name keys
189
209
  # @return [void]
190
210
  # @see Base#process_function_calls
191
211
  def process_function_calls(api_function_calls)
192
- api_function_calls.each do |api_function_call|
193
- output = instrument("tool_call.active_agent", tool_name: api_function_call[:name]) do
194
- process_tool_call_function(api_function_call).to_json
212
+ results = dispatch_tool_calls(api_function_calls) do |api_function_call|
213
+ instrument("tool_call.active_agent", tool_name: api_function_call[:name]) do
214
+ process_tool_call_function(api_function_call)
195
215
  end
216
+ end
217
+ return unless results
196
218
 
219
+ api_function_calls.zip(results).each do |api_function_call, result|
197
220
  # Create native gem input item for function call output
198
221
  message = ::OpenAI::Models::Responses::ResponseInputItem::FunctionCallOutput.new(
199
222
  call_id: api_function_call[:call_id],
200
- output:
223
+ output: result.to_json
201
224
  )
202
225
 
203
226
  # Convert to hash for message_stack
@@ -205,6 +228,36 @@ module ActiveAgent
205
228
  end
206
229
  end
207
230
 
231
+ # A function_call item's `id` names the item; `call_id` is what its
232
+ # function_call_output answers.
233
+ #
234
+ # @see InputRequests#tool_call_reference
235
+ # @param api_function_call [Hash]
236
+ # @return [Array(String, String)]
237
+ def tool_call_reference(api_function_call)
238
+ [ api_function_call[:call_id].to_s, api_function_call[:name].to_s ]
239
+ end
240
+
241
+ # @see InputRequests#tool_call_arguments
242
+ # @param api_function_call [Hash]
243
+ # @return [Hash, String, nil]
244
+ def tool_call_arguments(api_function_call)
245
+ parse_tool_call_arguments(api_function_call[:arguments])
246
+ end
247
+
248
+ # Returns `messages` as the request's `input` items. Serializing the
249
+ # whole request would shorten a lone user message to a bare string.
250
+ #
251
+ # @see InputRequests#serialized_messages
252
+ # @return [Array<Hash>]
253
+ def serialized_messages(messages, instructions: nil)
254
+ parameters = { messages:, instructions: }.compact
255
+ return [] if parameters.empty?
256
+
257
+ input = Responses::Transforms.gem_to_hash(prompt_request_type.cast(parameters).__getobj__)[:input]
258
+ input.is_a?(String) ? [ { role: "user", content: input } ] : Array(input)
259
+ end
260
+
208
261
  # Converts OpenAI gem response object to hash for storage.
209
262
  #
210
263
  # @param api_response [OpenAI::Models::Responses::Response]
@@ -46,7 +46,10 @@ module ActiveAgent
46
46
  # site_url from Rails routes/ActionMailer default_url_options.
47
47
  #
48
48
  # @param kwargs [Hash] configuration options
49
- # @option kwargs [String] :api_key OpenRouter API key
49
+ # @option kwargs [String] :api_key OpenRouter API key. When neither it nor
50
+ # :access_token is given, falls back to OPENROUTER_API_KEY,
51
+ # OPEN_ROUTER_API_KEY, OPENROUTER_ACCESS_TOKEN, then OPEN_ROUTER_ACCESS_TOKEN.
52
+ # @option kwargs [String] :access_token alias for :api_key
50
53
  # @option kwargs [String] :app_name application name for rankings
51
54
  # @option kwargs [String] :site_url site URL for rankings
52
55
  # @return [Options]
@@ -85,7 +88,8 @@ module ActiveAgent
85
88
  private
86
89
 
87
90
  def resolve_api_key(kwargs)
88
- kwargs["api_key"] ||
91
+ kwargs[:api_key] ||
92
+ kwargs[:access_token] ||
89
93
  ENV["OPENROUTER_API_KEY"] ||
90
94
  ENV["OPEN_ROUTER_API_KEY"] ||
91
95
  ENV["OPENROUTER_ACCESS_TOKEN"] ||
@@ -34,8 +34,10 @@ module ActiveAgent
34
34
  when Array
35
35
  grouped = []
36
36
 
37
+ # Each tool result answers one call by its tool_call_id, so tool
38
+ # messages are never merged.
37
39
  value.each do |message|
38
- if grouped.empty? || grouped.last.role != message.role
40
+ if grouped.empty? || grouped.last.role != message.role || message.role == "tool"
39
41
  grouped << message.deep_dup
40
42
  else
41
43
  grouped.last.content += message.content.deep_dup
@@ -15,6 +15,11 @@ module ActiveAgent
15
15
  # e.g. :vertexai, :gemini, :bedrock). A model ID served by several
16
16
  # backends otherwise resolves by RubyLLM's registry preference.
17
17
  attribute :platform, :string
18
+ # Pins which of the backend's wire protocols carries a request
19
+ # (RubyLLM's protocol:, e.g. :chat_completions or :responses for
20
+ # OpenAI). ruby_llm 2.x only; without it RubyLLM's own setting
21
+ # applies, and for OpenAI that defaults to :responses.
22
+ attribute :protocol, :string
18
23
  attribute :temperature, :float
19
24
  attribute :max_tokens, :integer
20
25