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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +175 -0
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
- data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
- data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
- data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
- data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
- data/lib/ruby_llm/active_record/acts_as.rb +174 -0
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
- data/lib/ruby_llm/active_record/message_methods.rb +81 -0
- data/lib/ruby_llm/active_record/model_methods.rb +84 -0
- data/lib/ruby_llm/aliases.json +295 -0
- data/lib/ruby_llm/aliases.rb +38 -0
- data/lib/ruby_llm/attachment.rb +220 -0
- data/lib/ruby_llm/chat.rb +816 -0
- data/lib/ruby_llm/chunk.rb +6 -0
- data/lib/ruby_llm/configuration.rb +78 -0
- data/lib/ruby_llm/connection.rb +126 -0
- data/lib/ruby_llm/content.rb +73 -0
- data/lib/ruby_llm/context.rb +29 -0
- data/lib/ruby_llm/embedding.rb +29 -0
- data/lib/ruby_llm/error.rb +84 -0
- data/lib/ruby_llm/image.rb +49 -0
- data/lib/ruby_llm/message.rb +86 -0
- data/lib/ruby_llm/mime_type.rb +71 -0
- data/lib/ruby_llm/model/info.rb +111 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +48 -0
- data/lib/ruby_llm/model/pricing_category.rb +46 -0
- data/lib/ruby_llm/model/pricing_tier.rb +33 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +33198 -0
- data/lib/ruby_llm/models.rb +231 -0
- data/lib/ruby_llm/models_schema.json +168 -0
- data/lib/ruby_llm/moderation.rb +56 -0
- data/lib/ruby_llm/provider.rb +243 -0
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
- data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
- data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
- data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
- data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
- data/lib/ruby_llm/providers/anthropic.rb +36 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
- data/lib/ruby_llm/providers/bedrock.rb +82 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
- data/lib/ruby_llm/providers/deepseek.rb +30 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
- data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
- data/lib/ruby_llm/providers/gemini/images.rb +47 -0
- data/lib/ruby_llm/providers/gemini/media.rb +112 -0
- data/lib/ruby_llm/providers/gemini/models.rb +40 -0
- data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
- data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
- data/lib/ruby_llm/providers/gemini.rb +37 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
- data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
- data/lib/ruby_llm/providers/gpustack.rb +34 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/mistral/models.rb +48 -0
- data/lib/ruby_llm/providers/mistral.rb +32 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
- data/lib/ruby_llm/providers/ollama/media.rb +46 -0
- data/lib/ruby_llm/providers/ollama/models.rb +36 -0
- data/lib/ruby_llm/providers/ollama.rb +30 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
- data/lib/ruby_llm/providers/openai/chat.rb +88 -0
- data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/openai/images.rb +38 -0
- data/lib/ruby_llm/providers/openai/media.rb +81 -0
- data/lib/ruby_llm/providers/openai/models.rb +39 -0
- data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
- data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
- data/lib/ruby_llm/providers/openai/tools.rb +98 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
- data/lib/ruby_llm/providers/openai.rb +44 -0
- data/lib/ruby_llm/providers/openai_responses.rb +395 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
- data/lib/ruby_llm/providers/openrouter.rb +26 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
- data/lib/ruby_llm/providers/perplexity.rb +48 -0
- data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
- data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
- data/lib/ruby_llm/providers/vertexai.rb +55 -0
- data/lib/ruby_llm/railtie.rb +35 -0
- data/lib/ruby_llm/responses_session.rb +77 -0
- data/lib/ruby_llm/stream_accumulator.rb +101 -0
- data/lib/ruby_llm/streaming.rb +153 -0
- data/lib/ruby_llm/tool.rb +209 -0
- data/lib/ruby_llm/tool_call.rb +22 -0
- data/lib/ruby_llm/tool_executors.rb +125 -0
- data/lib/ruby_llm/transcription.rb +35 -0
- data/lib/ruby_llm/utils.rb +91 -0
- data/lib/ruby_llm/version.rb +5 -0
- data/lib/ruby_llm.rb +140 -0
- data/lib/tasks/models.rake +525 -0
- data/lib/tasks/release.rake +67 -0
- data/lib/tasks/ruby_llm.rake +15 -0
- data/lib/tasks/vcr.rake +92 -0
- 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
|