Skip to content

[FEATURE] allow multiple callback definitions #544

@smcabrera

Description

@smcabrera

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 response

This 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 both

This 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 callback

I'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?'
end

But 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions