ruby_llm_swarm 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +175 -0
  4. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  22. data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
  23. data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
  24. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  25. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  26. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
  27. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
  29. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
  30. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
  31. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  32. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  33. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  34. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  35. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
  36. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  37. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  38. data/lib/ruby_llm/active_record/acts_as.rb +174 -0
  39. data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
  40. data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
  41. data/lib/ruby_llm/active_record/message_methods.rb +81 -0
  42. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  43. data/lib/ruby_llm/aliases.json +295 -0
  44. data/lib/ruby_llm/aliases.rb +38 -0
  45. data/lib/ruby_llm/attachment.rb +220 -0
  46. data/lib/ruby_llm/chat.rb +816 -0
  47. data/lib/ruby_llm/chunk.rb +6 -0
  48. data/lib/ruby_llm/configuration.rb +78 -0
  49. data/lib/ruby_llm/connection.rb +126 -0
  50. data/lib/ruby_llm/content.rb +73 -0
  51. data/lib/ruby_llm/context.rb +29 -0
  52. data/lib/ruby_llm/embedding.rb +29 -0
  53. data/lib/ruby_llm/error.rb +84 -0
  54. data/lib/ruby_llm/image.rb +49 -0
  55. data/lib/ruby_llm/message.rb +86 -0
  56. data/lib/ruby_llm/mime_type.rb +71 -0
  57. data/lib/ruby_llm/model/info.rb +111 -0
  58. data/lib/ruby_llm/model/modalities.rb +22 -0
  59. data/lib/ruby_llm/model/pricing.rb +48 -0
  60. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  61. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  62. data/lib/ruby_llm/model.rb +7 -0
  63. data/lib/ruby_llm/models.json +33198 -0
  64. data/lib/ruby_llm/models.rb +231 -0
  65. data/lib/ruby_llm/models_schema.json +168 -0
  66. data/lib/ruby_llm/moderation.rb +56 -0
  67. data/lib/ruby_llm/provider.rb +243 -0
  68. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  69. data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
  70. data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
  71. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  72. data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
  73. data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
  74. data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
  75. data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
  76. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  77. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  78. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  79. data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
  80. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  81. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  82. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  83. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
  84. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  85. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
  86. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  87. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  88. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  89. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  90. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  91. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  92. data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
  93. data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
  94. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  95. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  96. data/lib/ruby_llm/providers/gemini/media.rb +112 -0
  97. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  98. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  99. data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
  100. data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
  101. data/lib/ruby_llm/providers/gemini.rb +37 -0
  102. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  103. data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
  104. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  105. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  106. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  107. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  108. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  109. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  110. data/lib/ruby_llm/providers/mistral.rb +32 -0
  111. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  112. data/lib/ruby_llm/providers/ollama/media.rb +46 -0
  113. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  114. data/lib/ruby_llm/providers/ollama.rb +30 -0
  115. data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
  116. data/lib/ruby_llm/providers/openai/chat.rb +88 -0
  117. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  118. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  119. data/lib/ruby_llm/providers/openai/media.rb +81 -0
  120. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  121. data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
  122. data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
  123. data/lib/ruby_llm/providers/openai/tools.rb +98 -0
  124. data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
  125. data/lib/ruby_llm/providers/openai.rb +44 -0
  126. data/lib/ruby_llm/providers/openai_responses.rb +395 -0
  127. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  128. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  129. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  130. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  131. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  132. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  133. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  134. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  135. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  136. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  137. data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
  138. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  139. data/lib/ruby_llm/railtie.rb +35 -0
  140. data/lib/ruby_llm/responses_session.rb +77 -0
  141. data/lib/ruby_llm/stream_accumulator.rb +101 -0
  142. data/lib/ruby_llm/streaming.rb +153 -0
  143. data/lib/ruby_llm/tool.rb +209 -0
  144. data/lib/ruby_llm/tool_call.rb +22 -0
  145. data/lib/ruby_llm/tool_executors.rb +125 -0
  146. data/lib/ruby_llm/transcription.rb +35 -0
  147. data/lib/ruby_llm/utils.rb +91 -0
  148. data/lib/ruby_llm/version.rb +5 -0
  149. data/lib/ruby_llm.rb +140 -0
  150. data/lib/tasks/models.rake +525 -0
  151. data/lib/tasks/release.rake +67 -0
  152. data/lib/tasks/ruby_llm.rake +15 -0
  153. data/lib/tasks/vcr.rake +92 -0
  154. metadata +346 -0
@@ -0,0 +1,816 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module RubyLLM
6
+ # Represents a conversation with an AI model
7
+ class Chat
8
+ include Enumerable
9
+
10
+ # Represents an active subscription to a callback event.
11
+ # Returned by {#subscribe} and can be used to unsubscribe later.
12
+ class Subscription
13
+ attr_reader :tag
14
+
15
+ def initialize(callback_list, callback, monitor:, tag: nil)
16
+ @callback_list = callback_list
17
+ @callback = callback
18
+ @monitor = monitor
19
+ @tag = tag
20
+ @active = true
21
+ end
22
+
23
+ # Removes this subscription from the callback list.
24
+ # @return [Boolean] true if successfully unsubscribed, false if already inactive
25
+ def unsubscribe # rubocop:disable Naming/PredicateMethod
26
+ @monitor.synchronize do
27
+ return false unless @active
28
+
29
+ @callback_list.delete(@callback)
30
+ @active = false
31
+ end
32
+ true
33
+ end
34
+
35
+ # Checks if this subscription is still active.
36
+ # @return [Boolean] true if still subscribed
37
+ def active?
38
+ @monitor.synchronize do
39
+ @active && @callback_list.include?(@callback)
40
+ end
41
+ end
42
+
43
+ def inspect
44
+ "#<#{self.class.name} tag=#{@tag.inspect} active=#{active?}>"
45
+ end
46
+ end
47
+
48
+ attr_reader :model, :messages, :tools, :params, :headers, :schema, :tool_concurrency, :max_concurrency,
49
+ :responses_session
50
+
51
+ def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil, # rubocop:disable Metrics/ParameterLists
52
+ tool_concurrency: nil, max_concurrency: nil)
53
+ if assume_model_exists && !provider
54
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
55
+ end
56
+
57
+ @context = context
58
+ @config = context&.config || RubyLLM.config
59
+ model_id = model || @config.default_model
60
+ with_model(model_id, provider: provider, assume_exists: assume_model_exists)
61
+ @temperature = nil
62
+ @messages = []
63
+ @messages_mutex = Mutex.new
64
+ @tools = {}
65
+ @params = {}
66
+ @headers = {}
67
+ @schema = nil
68
+
69
+ # Concurrent tool execution settings
70
+ @tool_concurrency = tool_concurrency
71
+ @max_concurrency = max_concurrency
72
+
73
+ # Responses API state
74
+ @responses_api_config = nil
75
+ @responses_session = nil
76
+
77
+ # Multi-subscriber callback system
78
+ @callbacks = {
79
+ new_message: [],
80
+ end_message: [],
81
+ tool_call: [],
82
+ tool_result: []
83
+ }
84
+ @callback_monitor = Monitor.new
85
+
86
+ # Extensibility hook for tool execution
87
+ @around_tool_execution_hook = nil
88
+
89
+ # Extensibility hook for LLM requests
90
+ @around_llm_request_hook = nil
91
+ end
92
+
93
+ def ask(message = nil, with: nil, &)
94
+ add_message role: :user, content: build_content(message, with)
95
+ complete(&)
96
+ end
97
+
98
+ alias say ask
99
+
100
+ def with_instructions(instructions, replace: false)
101
+ @messages = @messages.reject { |msg| msg.role == :system } if replace
102
+
103
+ add_message role: :system, content: instructions
104
+ self
105
+ end
106
+
107
+ def with_tool(tool)
108
+ tool_instance = tool.is_a?(Class) ? tool.new : tool
109
+ @tools[tool_instance.name.to_sym] = tool_instance
110
+ self
111
+ end
112
+
113
+ def with_tools(*tools, replace: false)
114
+ @tools.clear if replace
115
+ tools.compact.each { |tool| with_tool tool }
116
+ self
117
+ end
118
+
119
+ def with_model(model_id, provider: nil, assume_exists: false)
120
+ @model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
121
+ @connection = @provider.connection
122
+ self
123
+ end
124
+
125
+ def with_temperature(temperature)
126
+ @temperature = temperature
127
+ self
128
+ end
129
+
130
+ def with_context(context)
131
+ @context = context
132
+ @config = context.config
133
+ with_model(@model.id, provider: @provider.slug, assume_exists: true)
134
+ self
135
+ end
136
+
137
+ def with_params(**params)
138
+ @params = params
139
+ self
140
+ end
141
+
142
+ def with_headers(**headers)
143
+ @headers = headers
144
+ self
145
+ end
146
+
147
+ def with_schema(schema)
148
+ schema_instance = schema.is_a?(Class) ? schema.new : schema
149
+
150
+ # Accept both RubyLLM::Schema instances and plain JSON schemas
151
+ @schema = if schema_instance.respond_to?(:to_json_schema)
152
+ schema_instance.to_json_schema[:schema]
153
+ else
154
+ schema_instance
155
+ end
156
+
157
+ self
158
+ end
159
+
160
+ # Configures concurrent tool execution for this chat.
161
+ #
162
+ # @param mode [Symbol, nil] Concurrency mode (:async, :threads, or nil for sequential)
163
+ # @param max [Integer, nil] Maximum number of concurrent tool executions
164
+ # @return [self] for chaining
165
+ #
166
+ # @example
167
+ # chat.with_tool_concurrency(:async, max: 5)
168
+ # .with_tools(Weather, Stock, Currency)
169
+ # .ask("Get weather, stock price, and currency rate")
170
+ def with_tool_concurrency(mode = nil, max: nil)
171
+ @tool_concurrency = mode unless mode.nil?
172
+ @max_concurrency = max if max
173
+ self
174
+ end
175
+
176
+ # Enables OpenAI Responses API for this chat.
177
+ # Switches from chat/completions to the v1/responses endpoint.
178
+ #
179
+ # @param stateful [Boolean] Use previous_response_id for efficient multi-turn (default: false)
180
+ # @param store [Boolean] Store responses on OpenAI server (default: true)
181
+ # @param truncation [Symbol] Truncation strategy (default: :disabled)
182
+ # @param include [Array<Symbol>] Additional data to include (e.g., [:reasoning_encrypted_content])
183
+ # @return [self] for chaining
184
+ #
185
+ # @example Basic usage
186
+ # chat.with_responses_api.ask("Hello")
187
+ #
188
+ # @example Stateful mode for token efficiency
189
+ # chat.with_responses_api(stateful: true).ask("Hello")
190
+ #
191
+ # @example With custom configuration
192
+ # chat.with_responses_api(
193
+ # stateful: true,
194
+ # truncation: :auto,
195
+ # include: [:reasoning_encrypted_content]
196
+ # ).ask("Complex reasoning task")
197
+ def with_responses_api(stateful: false, store: true, truncation: :disabled, include: [], **options)
198
+ unless @provider.is_a?(Providers::OpenAI)
199
+ raise ArgumentError, 'with_responses_api is only supported for OpenAI providers'
200
+ end
201
+
202
+ @responses_api_config = {
203
+ stateful: stateful,
204
+ store: store,
205
+ truncation: truncation,
206
+ include: include,
207
+ service_tier: options[:service_tier],
208
+ max_tool_calls: options[:max_tool_calls]
209
+ }
210
+
211
+ # Initialize session if not already present
212
+ @responses_session ||= ResponsesSession.new
213
+
214
+ # Switch to OpenAIResponses provider if currently using standard OpenAI
215
+ unless @provider.is_a?(Providers::OpenAIResponses)
216
+ @provider = Providers::OpenAIResponses.new(@config, @responses_session, @responses_api_config)
217
+ @connection = @provider.connection
218
+ end
219
+
220
+ self
221
+ end
222
+
223
+ # Restores a Responses API session from previously saved state.
224
+ # Used for persisting sessions across requests (e.g., Rails).
225
+ #
226
+ # @param session_data [Hash] Session data from ResponsesSession#to_h
227
+ # @return [self] for chaining
228
+ def restore_responses_session(session_data)
229
+ @responses_session = ResponsesSession.from_h(session_data)
230
+
231
+ # Update provider session if already using Responses API
232
+ if @provider.is_a?(Providers::OpenAIResponses)
233
+ @provider = Providers::OpenAIResponses.new(@config, @responses_session, @responses_api_config || {})
234
+ @connection = @provider.connection
235
+ end
236
+
237
+ self
238
+ end
239
+
240
+ # Checks if the Responses API is currently enabled for this chat.
241
+ #
242
+ # @return [Boolean] true if using OpenAI Responses API
243
+ def responses_api_enabled?
244
+ @provider.is_a?(Providers::OpenAIResponses)
245
+ end
246
+
247
+ # Subscribes to an event with the given block.
248
+ # Returns a {Subscription} that can be used to unsubscribe.
249
+ #
250
+ # @param event [Symbol] The event to subscribe to (:new_message, :end_message, :tool_call, :tool_result)
251
+ # @param tag [String, nil] Optional tag for debugging/identification
252
+ # @yield The block to call when the event fires
253
+ # @return [Subscription] An object that can be used to unsubscribe
254
+ # @raise [ArgumentError] if event is not recognized
255
+ #
256
+ # @example
257
+ # sub = chat.subscribe(:tool_call, tag: "metrics") { |tc| track(tc) }
258
+ # # ... later
259
+ # sub.unsubscribe
260
+ def subscribe(event, tag: nil, &block)
261
+ @callback_monitor.synchronize do
262
+ unless @callbacks.key?(event)
263
+ raise ArgumentError, "Unknown event: #{event}. Valid events: #{@callbacks.keys.join(', ')}"
264
+ end
265
+
266
+ @callbacks[event] << block
267
+ Subscription.new(@callbacks[event], block, monitor: @callback_monitor, tag: tag)
268
+ end
269
+ end
270
+
271
+ # Subscribes to an event that automatically unsubscribes after firing once.
272
+ #
273
+ # @param event [Symbol] The event to subscribe to
274
+ # @param tag [String, nil] Optional tag for debugging/identification
275
+ # @yield The block to call when the event fires (once)
276
+ # @return [Subscription] An object that can be used to unsubscribe before it fires
277
+ #
278
+ # @example
279
+ # chat.once(:end_message) { |msg| setup_initial_state(msg) }
280
+ def once(event, tag: nil, &block)
281
+ subscription = nil
282
+ wrapper = lambda do |*args|
283
+ subscription&.unsubscribe
284
+ block.call(*args)
285
+ end
286
+ subscription = subscribe(event, tag: tag, &wrapper)
287
+ end
288
+
289
+ # Registers a callback for when a new message starts being generated.
290
+ # Multiple callbacks can be registered and all will fire in registration order.
291
+ #
292
+ # @yield Block called when a new message starts
293
+ # @return [self] for chaining
294
+ def on_new_message(&)
295
+ subscribe(:new_message, &)
296
+ self
297
+ end
298
+
299
+ # Registers a callback for when a message is complete.
300
+ # Multiple callbacks can be registered and all will fire in registration order.
301
+ #
302
+ # @yield [Message] Block called with the completed message
303
+ # @return [self] for chaining
304
+ def on_end_message(&)
305
+ subscribe(:end_message, &)
306
+ self
307
+ end
308
+
309
+ # Registers a callback for when a tool is called.
310
+ # Multiple callbacks can be registered and all will fire in registration order.
311
+ #
312
+ # @yield [ToolCall] Block called with the tool call object
313
+ # @return [self] for chaining
314
+ def on_tool_call(&)
315
+ subscribe(:tool_call, &)
316
+ self
317
+ end
318
+
319
+ # Registers a callback for when a tool returns a result.
320
+ # Multiple callbacks can be registered and all will fire in registration order.
321
+ #
322
+ # @yield [ToolCall, Object] Block called with the tool call and its result
323
+ # @return [self] for chaining
324
+ def on_tool_result(&)
325
+ subscribe(:tool_result, &)
326
+ self
327
+ end
328
+
329
+ # Sets a hook to wrap tool execution with custom behavior.
330
+ # Unlike event callbacks, only one around hook can be active at a time.
331
+ #
332
+ # The block receives:
333
+ # - tool_call [ToolCall]: The tool call being executed (.name, .arguments, .id)
334
+ # - tool_instance [Tool]: The tool instance
335
+ # - execute [Proc]: Call this to execute the actual tool
336
+ #
337
+ # The block must return the result (can modify or replace it).
338
+ # If the block doesn't call execute.call, it can return an alternative result (for caching, mocking, etc.).
339
+ #
340
+ # @yield [ToolCall, Tool, Proc] Block called for each tool execution
341
+ # @return [self] for chaining
342
+ #
343
+ # @example Logging and timing
344
+ # chat.around_tool_execution do |tool_call, tool_instance, execute|
345
+ # start = Time.now
346
+ # result = execute.call
347
+ # puts "#{tool_call.name} took #{Time.now - start}s"
348
+ # result
349
+ # end
350
+ #
351
+ # @example Caching
352
+ # chat.around_tool_execution do |tool_call, tool_instance, execute|
353
+ # cache_key = [tool_call.name, tool_call.arguments].hash
354
+ # Rails.cache.fetch(cache_key) { execute.call }
355
+ # end
356
+ def around_tool_execution(&block)
357
+ @around_tool_execution_hook = block
358
+ self
359
+ end
360
+
361
+ # Sets a hook to wrap LLM API requests with custom behavior.
362
+ # Unlike event callbacks, only one around hook can be active at a time.
363
+ #
364
+ # The block receives:
365
+ # - messages [Array<Message>]: The current conversation messages
366
+ # - send_request [Proc]: A block that executes the actual provider call
367
+ #
368
+ # The block MUST call the block and return the response (can be modified).
369
+ # This allows intercepting, modifying messages, adding retry logic, etc.
370
+ #
371
+ # @yield [Array<Message>] Block called before each LLM request
372
+ # @yieldparam send_request [Proc] Block that sends request to provider
373
+ # @return [self] for chaining
374
+ #
375
+ # @example Inject ephemeral context (not stored in history)
376
+ # chat.around_llm_request do |messages, &send_request|
377
+ # prepared = messages + [ephemeral_message]
378
+ # send_request.call(prepared)
379
+ # end
380
+ #
381
+ # @example Add retry logic with backoff
382
+ # chat.around_llm_request do |messages, &send_request|
383
+ # retries = 0
384
+ # begin
385
+ # send_request.call(messages)
386
+ # rescue RubyLLM::RateLimitError => e
387
+ # sleep(2 ** retries)
388
+ # retry if (retries += 1) < 3
389
+ # raise
390
+ # end
391
+ # end
392
+ #
393
+ # @example Request logging
394
+ # chat.around_llm_request do |messages, &send_request|
395
+ # start = Time.now
396
+ # response = send_request.call(messages)
397
+ # puts "LLM request took #{Time.now - start}s"
398
+ # response
399
+ # end
400
+ def around_llm_request(&block)
401
+ @around_llm_request_hook = block
402
+ self
403
+ end
404
+
405
+ # Clears all callbacks for the specified event, or all events if none specified.
406
+ #
407
+ # @param event [Symbol, nil] The event to clear callbacks for, or nil for all events
408
+ # @return [self] for chaining
409
+ def clear_callbacks(event = nil)
410
+ @callback_monitor.synchronize do
411
+ if event
412
+ @callbacks[event]&.clear
413
+ else
414
+ @callbacks.each_value(&:clear)
415
+ end
416
+ end
417
+ self
418
+ end
419
+
420
+ # Returns the number of callbacks registered for the specified event.
421
+ #
422
+ # @param event [Symbol, nil] The event to count callbacks for, or nil for all events
423
+ # @return [Integer, Hash] Count for specific event, or hash of counts for all events
424
+ def callback_count(event = nil)
425
+ @callback_monitor.synchronize do
426
+ if event
427
+ @callbacks[event]&.size || 0
428
+ else
429
+ @callbacks.transform_values(&:size)
430
+ end
431
+ end
432
+ end
433
+
434
+ def each(&)
435
+ messages.each(&)
436
+ end
437
+
438
+ def complete(&)
439
+ response = execute_llm_request(&)
440
+
441
+ emit(:new_message) unless block_given?
442
+ update_responses_session(response)
443
+ parse_schema_response(response)
444
+
445
+ if response.tool_call?
446
+ execute_tool_call_sequence(response, &)
447
+ else
448
+ add_message response
449
+ emit(:end_message, response)
450
+ response
451
+ end
452
+ end
453
+
454
+ # Adds a message to the conversation history.
455
+ # Thread-safe: uses mutex to protect message array.
456
+ #
457
+ # @param message_or_attributes [Message, Hash] A Message object or hash of attributes
458
+ # @return [Message] The added message
459
+ def add_message(message_or_attributes)
460
+ message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
461
+ @messages_mutex.synchronize do
462
+ @messages << message
463
+ end
464
+ message
465
+ end
466
+
467
+ # Returns a thread-safe, frozen snapshot of the message history.
468
+ # Use this for safe reading when concurrent operations may be modifying messages.
469
+ #
470
+ # @return [Array<Message>] Frozen copy of the messages array
471
+ def message_history
472
+ @messages_mutex.synchronize { @messages.dup.freeze }
473
+ end
474
+
475
+ # Replaces the entire message history with new messages.
476
+ # Thread-safe: uses mutex to protect message array.
477
+ #
478
+ # @param new_messages [Array<Message, Hash>] New messages to set
479
+ # @return [self] for chaining
480
+ def set_messages(new_messages) # rubocop:disable Naming/AccessorMethodName
481
+ @messages_mutex.synchronize do
482
+ @messages.clear
483
+ new_messages.each do |msg|
484
+ @messages << (msg.is_a?(Message) ? msg : Message.new(msg))
485
+ end
486
+ end
487
+ self
488
+ end
489
+
490
+ # Creates a snapshot of the current message history for checkpointing.
491
+ # Thread-safe: uses mutex to protect message array.
492
+ #
493
+ # @return [Array<Message>] Duplicated messages for restoration later
494
+ def snapshot_messages
495
+ @messages_mutex.synchronize { @messages.map(&:dup) }
496
+ end
497
+
498
+ # Restores messages from a previously taken snapshot.
499
+ #
500
+ # @param snapshot [Array<Message>] Previously saved message snapshot
501
+ # @return [self] for chaining
502
+ def restore_messages(snapshot)
503
+ set_messages(snapshot)
504
+ end
505
+
506
+ # Clears messages from the conversation history.
507
+ # Thread-safe: uses mutex to protect message array.
508
+ #
509
+ # @param preserve_system_prompt [Boolean] if true (default), keeps system messages
510
+ # @return [self] for chaining
511
+ def reset_messages!(preserve_system_prompt: true)
512
+ @messages_mutex.synchronize do
513
+ if preserve_system_prompt
514
+ @messages.select! { |m| m.role == :system }
515
+ else
516
+ @messages.clear
517
+ end
518
+ end
519
+ self
520
+ end
521
+
522
+ # Wraps operations in a transaction for rollback on failure.
523
+ # If an exception is raised, all messages added since the transaction started are removed.
524
+ # This ensures Chat state remains valid even on cancellation or errors.
525
+ #
526
+ # Uses O(1) memory (just tracks index, no array duplication).
527
+ #
528
+ # @yield Block to execute within the transaction
529
+ # @return [Object] Result of the block
530
+ # @raise Re-raises any exception after rolling back
531
+ def with_message_transaction
532
+ start_index = @messages_mutex.synchronize { @messages.size }
533
+
534
+ begin
535
+ yield
536
+ rescue StandardError => e
537
+ # Truncate back to where we started (O(1) operation)
538
+ @messages_mutex.synchronize do
539
+ @messages.slice!(start_index..-1)
540
+ end
541
+ raise e
542
+ end
543
+ end
544
+
545
+ # Checks if the last tool call has all corresponding results.
546
+ # Useful for diagnosing incomplete Chat state after interruptions.
547
+ #
548
+ # @return [Boolean] true if all tool calls have results
549
+ def tool_results_complete? # rubocop:disable Metrics/PerceivedComplexity
550
+ return true unless messages.any?
551
+
552
+ last_assistant = messages.reverse.find do |m|
553
+ m.role == :assistant && m.respond_to?(:tool_calls) && m.tool_calls&.any?
554
+ end
555
+ return true unless last_assistant
556
+
557
+ expected_ids = last_assistant.tool_calls.keys.to_set
558
+ actual_ids = messages.select { |m| m.role == :tool }.filter_map(&:tool_call_id).to_set
559
+
560
+ expected_ids.subset?(actual_ids)
561
+ end
562
+
563
+ # Removes incomplete tool call sequence if interrupted.
564
+ # Call this to repair Chat state after cancellation/exception.
565
+ #
566
+ # @return [self] for chaining
567
+ def repair_incomplete_tool_calls! # rubocop:disable Metrics/PerceivedComplexity
568
+ return self if tool_results_complete?
569
+
570
+ @messages_mutex.synchronize do
571
+ # Remove partial tool results
572
+ @messages.pop while @messages.last&.role == :tool
573
+
574
+ # Remove the incomplete assistant message with tool_calls
575
+ last = @messages.last
576
+ @messages.pop if last&.role == :assistant && last.respond_to?(:tool_calls) && last.tool_calls&.any?
577
+ end
578
+ self
579
+ end
580
+
581
+ def instance_variables
582
+ super - %i[@connection @config @messages_mutex @callback_monitor]
583
+ end
584
+
585
+ private
586
+
587
+ # Executes the LLM request, wrapping with the around_llm_request hook if configured.
588
+ # The hook receives the messages and a proc to execute the actual provider call.
589
+ #
590
+ # @yield [Chunk] Optional streaming block
591
+ # @return [Message] The response from the provider
592
+ def execute_llm_request(&streaming_block)
593
+ provider_call = lambda do |msgs|
594
+ @provider.complete(
595
+ msgs,
596
+ tools: @tools,
597
+ temperature: @temperature,
598
+ model: @model,
599
+ params: @params,
600
+ headers: @headers,
601
+ schema: @schema,
602
+ &wrap_streaming_block(&streaming_block)
603
+ )
604
+ end
605
+
606
+ if @around_llm_request_hook
607
+ @around_llm_request_hook.call(messages, &provider_call)
608
+ else
609
+ provider_call.call(messages)
610
+ end
611
+ end
612
+
613
+ def wrap_streaming_block(&block)
614
+ return nil unless block_given?
615
+
616
+ first_chunk_received = false
617
+
618
+ proc do |chunk|
619
+ # Emit new_message on first content chunk
620
+ unless first_chunk_received
621
+ first_chunk_received = true
622
+ emit(:new_message)
623
+ end
624
+
625
+ block.call chunk
626
+ end
627
+ end
628
+
629
+ def handle_tool_calls(response, &)
630
+ halt_result = if concurrent_tools?
631
+ execute_tools_concurrently(response.tool_calls)
632
+ else
633
+ execute_tools_sequentially(response.tool_calls)
634
+ end
635
+
636
+ halt_result || complete(&)
637
+ end
638
+
639
+ # Executes tool call sequence within a transaction for atomicity.
640
+ # If interrupted or an error occurs, the assistant message and any partial
641
+ # tool results are rolled back, keeping Chat state valid.
642
+ def execute_tool_call_sequence(response, &block)
643
+ with_message_transaction do
644
+ # Add assistant message (inside transaction)
645
+ add_message response
646
+ emit(:end_message, response)
647
+
648
+ # Execute tools and potentially recurse
649
+ handle_tool_calls(response, &block)
650
+ end
651
+ end
652
+
653
+ # Executes tools sequentially (one at a time).
654
+ # Each tool fires all events and adds its message immediately.
655
+ def execute_tools_sequentially(tool_calls)
656
+ halt_result = nil
657
+
658
+ tool_calls.each_value do |tool_call|
659
+ result = execute_single_tool_with_message(tool_call)
660
+ halt_result = result if result.is_a?(Tool::Halt)
661
+ end
662
+
663
+ halt_result
664
+ end
665
+
666
+ # Executes tools concurrently using the configured executor.
667
+ # Uses hybrid pattern: fires events immediately, adds messages atomically.
668
+ def execute_tools_concurrently(tool_calls)
669
+ results = parallel_execute_tools(tool_calls)
670
+ add_tool_results_atomically(tool_calls, results)
671
+ find_first_halt(tool_calls, results)
672
+ end
673
+
674
+ # Executes tools in parallel using the registered executor.
675
+ def parallel_execute_tools(tool_calls)
676
+ executor = RubyLLM.tool_executors[@tool_concurrency]
677
+ raise ArgumentError, "Unknown tool executor: #{@tool_concurrency}" unless executor
678
+
679
+ executor.call(tool_calls.values, max_concurrency: @max_concurrency) do |tool_call|
680
+ execute_single_tool_with_events(tool_call)
681
+ end
682
+ end
683
+
684
+ # Executes a single tool with all events and immediate message addition.
685
+ # Used for sequential execution.
686
+ def execute_single_tool_with_message(tool_call)
687
+ emit(:new_message)
688
+ result = execute_single_tool(tool_call)
689
+ emit(:tool_result, tool_call, result)
690
+ message = add_tool_result_message(tool_call, result)
691
+ emit(:end_message, message)
692
+ result
693
+ end
694
+
695
+ # Executes a single tool with events but without message addition.
696
+ # Used for concurrent execution (messages added atomically later).
697
+ def execute_single_tool_with_events(tool_call)
698
+ emit(:new_message)
699
+ result = execute_single_tool(tool_call)
700
+ emit(:tool_result, tool_call, result)
701
+ result
702
+ end
703
+
704
+ # Core tool execution: fires tool_call event, runs the tool with extensibility hook.
705
+ def execute_single_tool(tool_call)
706
+ emit(:tool_call, tool_call)
707
+
708
+ tool_instance = tools[tool_call.name.to_sym]
709
+ execute_proc = -> { tool_instance.call(tool_call.arguments) }
710
+
711
+ if @around_tool_execution_hook
712
+ @around_tool_execution_hook.call(tool_call, tool_instance, execute_proc)
713
+ else
714
+ execute_proc.call
715
+ end
716
+ end
717
+
718
+ # Creates and adds a tool result message.
719
+ def add_tool_result_message(tool_call, result)
720
+ tool_payload = result.is_a?(Tool::Halt) ? result.content : result
721
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
722
+ add_message role: :tool, content: content, tool_call_id: tool_call.id
723
+ end
724
+
725
+ # Updates the Responses API session with the response ID if applicable.
726
+ def update_responses_session(response)
727
+ return unless responses_api_enabled?
728
+ return unless response.respond_to?(:response_id) && response.response_id
729
+
730
+ @responses_session.update(response.response_id)
731
+ end
732
+
733
+ # Parses JSON schema response content if applicable.
734
+ def parse_schema_response(response)
735
+ return unless @schema && response.content.is_a?(String)
736
+
737
+ response.content = JSON.parse(response.content)
738
+ rescue JSON::ParserError
739
+ # If parsing fails, keep content as string
740
+ end
741
+
742
+ # Adds all tool result messages atomically to ensure Chat state consistency.
743
+ # This prevents partial results that would make the Chat invalid for LLM APIs.
744
+ def add_tool_results_atomically(tool_calls, results)
745
+ messages = []
746
+
747
+ @messages_mutex.synchronize do
748
+ tool_calls.each_key do |id|
749
+ tool_call = tool_calls[id]
750
+ result = results[id]
751
+
752
+ tool_payload = result.is_a?(Tool::Halt) ? result.content : result
753
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
754
+ message = Message.new(role: :tool, content: content, tool_call_id: tool_call.id)
755
+ @messages << message
756
+ messages << message
757
+ end
758
+ end
759
+
760
+ # Fire events outside mutex to prevent blocking
761
+ messages.each { |msg| emit(:end_message, msg) }
762
+ end
763
+
764
+ # Finds the first halt result by request order (not completion order).
765
+ def find_first_halt(tool_calls, results)
766
+ tool_calls.each_key do |id|
767
+ result = results[id]
768
+ return result if result.is_a?(Tool::Halt)
769
+ end
770
+ nil
771
+ end
772
+
773
+ def build_content(message, attachments)
774
+ return message if content_like?(message)
775
+
776
+ Content.new(message, attachments)
777
+ end
778
+
779
+ def content_like?(object)
780
+ object.is_a?(Content) || object.is_a?(Content::Raw)
781
+ end
782
+
783
+ def concurrent_tools?
784
+ @tool_concurrency && @tool_concurrency != :sequential
785
+ end
786
+
787
+ # Emits an event to all registered subscribers.
788
+ # Callbacks are executed in registration order (FIFO).
789
+ # Errors in callbacks are isolated - one failing callback doesn't prevent others from running.
790
+ #
791
+ # @param event [Symbol] The event to emit
792
+ # @param args [Array] Arguments to pass to each callback
793
+ def emit(event, *args)
794
+ # Snapshot callbacks under lock (fast operation)
795
+ callbacks = @callback_monitor.synchronize { @callbacks[event].dup }
796
+
797
+ # Execute callbacks outside lock (safe, non-blocking)
798
+ callbacks.each do |callback|
799
+ callback.call(*args)
800
+ rescue StandardError => e
801
+ on_callback_error(event, callback, e)
802
+ end
803
+ end
804
+
805
+ # Hook for custom error handling when a callback raises an exception.
806
+ # Override this method in a subclass to customize error behavior.
807
+ #
808
+ # @param event [Symbol] The event that was being emitted
809
+ # @param callback [Proc] The callback that raised the error
810
+ # @param error [StandardError] The error that was raised
811
+ def on_callback_error(event, _callback, error)
812
+ warn "[RubyLLM] Callback error in #{event}: #{error.class} - #{error.message}"
813
+ warn error.backtrace.first(5).join("\n") if @config.respond_to?(:debug) && @config.debug
814
+ end
815
+ end
816
+ end