-
-
Notifications
You must be signed in to change notification settings - Fork 344
Description
Scope check
- This is core LLM communication (not application logic)
- This benefits most users (not just my use case)
- This can't be solved in application code with current RubyLLM it can be worked around, but it'd be a really nice QoL feature I think
- I read the Contributing Guide
Due diligence
- I searched existing issues
- I checked the documentation
What problem does this solve?
Currently if you define a second RubyLLM callback, it overrides the first one, which could be surprising:
chat = RubyLLM.chat
chat.on_new_message do
puts 'This is the first callback'
end
chat.on_new_message do
puts 'This is the second callback' # This DOES override the first callback
end
chat.ask 'What is metaprogramming in Ruby?'
# => This is the second callback
# ...the rest of the LLM responseThis behavior was surprising to me at first because of how other RubyLLM::Chat methods seem to allow chaining multiple methods to make additional changes to the chat, which could be at different call sites, or dynamically based on some logic. For example you can chain with_tool calls like this:
class AddNumbers < RubyLLM::Tool
description 'Adds two numbers together'
param :a, desc: 'First number'
param :b, desc: 'Second number'
def execute(a:, b:) = { result: a + b }
end
class Greet < RubyLLM::Tool
description 'Returns a greeting message'
param :name, desc: 'Name to greet'
def execute(name:) = { message: "Hello, #{name}!" }
end
chat.with_tool(AddNumbers)
chat.with_tool(Greet)
# Includes both tools
chat.tools # => [AddNumbers, Greet]It's also worth noting that callbacks in Rails' ActiveRecord work like this:
class MyModel < ActiveRecord::Base
def callback_one
puts '[Callback 1] after_initialize: First callback'
end
def callback_two
puts '[Callback 2] after_initialize: Second callback'
end
end
# Register multiple callbacks of the same type
MyModel.after_initialize :callback_one
MyModel.after_initialize :callback_two # This does NOT override the first callback
MyModel.new # => Calls bothThis is fine if you know all the callbacks you want to do in one place but if you're sharing logic through modules or inheritance this can be somewhat limiting. There's probably a way to work around this limitation but I think it would be a nice developer experience win for these use cases if we could support an API that didn't override previous callbacks.
Proposed solution
Looking at the way callbacks are handled stored as a hash of procs I think there's a a way we could do something similar to the way we handle tools today where calling with_tool again adds the new tool.
chat = RubyLLM.chat
chat.on_new_message do
puts 'This is the first callback'
end
chat.on_new_message do
puts 'This is the second callback' # This no longer override the first callback
end
chat.ask 'What is metaprogramming in Ruby?'
# => This is the first callback
# => This is the second callbackI'm happy to open a PR and take a stab at this.
Why this belongs in RubyLLM
I know this doesn't fulfill the whole checklist above: it's about the DSL more than LLM stuff specifically, and it's true that we could implement this in the application itself
something like this
# Helper method to add callbacks without overwriting existing ones
def add_callback(chat, callback_type, new_proc)
# Initialize or get the callbacks hash
on_callbacks = chat.instance_variable_get(:@on) || {}
# Initialize or get the array of procs for this callback type
# Store it as an instance variable on the chat object
callback_arrays = chat.instance_variable_get(:@_callback_arrays) || {}
callback_arrays[callback_type] ||= []
# Add the new proc to the array
callback_arrays[callback_type] << new_proc
# Wrap all procs in a single proc that calls each one
procs_array = callback_arrays[callback_type]
on_callbacks[callback_type] = proc { procs_array.each(&:call) }
# Store both back
chat.instance_variable_set(:@on, on_callbacks)
chat.instance_variable_set(:@_callback_arrays, callback_arrays)
end
ActiveRecord::Base.logger.silence do
chat = RubyLLM.chat
# Add callbacks one at a time - each call adds to the existing ones
add_callback(chat, :new_message, proc { puts 'First callback' })
add_callback(chat, :new_message, proc { puts 'Second callback' })
add_callback(chat, :new_message, proc { puts 'Third callback' })
chat.ask 'What is metaprogramming in Ruby?'
endBut RubyLLM is such a delightful library, "one beautiful API for all of them" as the docs say. I think this would be a nice quality of life feature for developers and it might avoid people running into application bugs in the future due to unexpected callback behavior.