Skip to content

Commit dce45e2

Browse files
Fix callback persistence chaining using Fanout pattern
- Implements `CallbackFanout` in `RubyLLM::Chat` to allow multiple callbacks per event. - Removes brittle wrapper workarounds in ActiveRecord integrations. - Ensures persistence callbacks are not overwritten when users define custom callbacks on the underlying LLM object. - Adds regression test for direct callback chaining.
1 parent 89605e2 commit dce45e2

File tree

5 files changed

+163
-38
lines changed

5 files changed

+163
-38
lines changed

lib/ruby_llm/active_record/acts_as_legacy.rb

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -152,26 +152,12 @@ def with_schema(...)
152152
end
153153

154154
def on_new_message(&block)
155-
to_llm
156-
157-
existing_callback = @chat.instance_variable_get(:@on)[:new_message]
158-
159-
@chat.on_new_message do
160-
existing_callback&.call
161-
block&.call
162-
end
155+
to_llm.on_new_message(&block)
163156
self
164157
end
165158

166159
def on_end_message(&block)
167-
to_llm
168-
169-
existing_callback = @chat.instance_variable_get(:@on)[:end_message]
170-
171-
@chat.on_end_message do |msg|
172-
existing_callback&.call(msg)
173-
block&.call(msg)
174-
end
160+
to_llm.on_end_message(&block)
175161
self
176162
end
177163

lib/ruby_llm/active_record/chat_methods.rb

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,26 +140,12 @@ def with_schema(...)
140140
end
141141

142142
def on_new_message(&block)
143-
to_llm
144-
145-
existing_callback = @chat.instance_variable_get(:@on)[:new_message]
146-
147-
@chat.on_new_message do
148-
existing_callback&.call
149-
block&.call
150-
end
143+
to_llm.on_new_message(&block)
151144
self
152145
end
153146

154147
def on_end_message(&block)
155-
to_llm
156-
157-
existing_callback = @chat.instance_variable_get(:@on)[:end_message]
158-
159-
@chat.on_end_message do |msg|
160-
existing_callback&.call(msg)
161-
block&.call(msg)
162-
end
148+
to_llm.on_end_message(&block)
163149
self
164150
end
165151

lib/ruby_llm/chat.rb

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ class Chat
77

88
attr_reader :model, :messages, :tools, :params, :headers, :schema
99

10+
# Hash-like container that stores arrays of callables and fans out calls to all of them
11+
class CallbackFanout
12+
def initialize
13+
@callbacks = {}
14+
end
15+
16+
# @return [Proc, nil] A callable that fans out to all callables in order
17+
def [](key)
18+
callables = Array(@callbacks[key]).compact
19+
return nil if callables.empty?
20+
21+
# Return a callable that fans out to all callables in order
22+
lambda do |*args, **kwargs, &block|
23+
callables.each { |c| c.call(*args, **kwargs, &block) }
24+
end
25+
end
26+
27+
# Appends the callable to the array instead of overwriting
28+
def []=(key, callable)
29+
return if callable.nil?
30+
31+
raise ArgumentError, 'The callback must be callable' unless callable.respond_to?(:call)
32+
33+
@callbacks[key] ||= []
34+
@callbacks[key] << callable
35+
end
36+
end
37+
1038
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
1139
if assume_model_exists && !provider
1240
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
@@ -22,12 +50,7 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
2250
@params = {}
2351
@headers = {}
2452
@schema = nil
25-
@on = {
26-
new_message: nil,
27-
end_message: nil,
28-
tool_call: nil,
29-
tool_result: nil
30-
}
53+
@on = CallbackFanout.new
3154
end
3255

3356
def ask(message = nil, with: nil, &)

spec/fixtures/vcr_cassettes/activerecord_actsas_event_callbacks_allows_chaining_callbacks_on_to_llm_without_losing_persistence.yml

Lines changed: 116 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/ruby_llm/active_record/acts_as_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,20 @@ def uploaded_file(path, type)
529529
expect(chat.messages.count).to eq(2) # Persistence still works
530530
end
531531

532+
it 'allows chaining callbacks on to_llm without losing persistence' do
533+
chat = Chat.create!(model: model)
534+
llm_chat = chat.to_llm
535+
536+
user_callback_called = false
537+
# Directly attach callback to the underlying Chat object
538+
llm_chat.on_new_message { user_callback_called = true }
539+
540+
chat.ask('Hello')
541+
542+
expect(user_callback_called).to be true
543+
expect(chat.messages.count).to eq(2) # Persistence still works
544+
end
545+
532546
it 'calls on_tool_call and on_tool_result callbacks' do
533547
tool_call_received = nil
534548
tool_result_received = nil

0 commit comments

Comments
 (0)