Skip to content

Commit 776d92a

Browse files
committed
Add additive chat callbacks
1 parent 983edf9 commit 776d92a

10 files changed

Lines changed: 269 additions & 70 deletions

File tree

docs/_core_features/agents.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ Delegated methods include:
243243
* `with_tool`, `with_tools`
244244
* `with_model`, `with_temperature`, `with_thinking`, `with_context`
245245
* `with_params`, `with_headers`, `with_schema`
246-
* `on_new_message`, `on_end_message`, `on_tool_call`, `on_tool_result`
246+
* `before_message`, `after_message`, `before_tool_call`, `after_tool_result` (v1.15+)
247+
* Deprecated replacing callbacks: `on_new_message`, `on_end_message`, `on_tool_call`, `on_tool_result`
247248

248249
You can always access the wrapped chat object directly via `agent.chat`.
249250

docs/_core_features/chat.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -653,18 +653,18 @@ You can register blocks to be called when certain events occur during the chat l
653653

654654
### Available Event Handlers
655655

656-
RubyLLM provides four event handlers that cover the complete chat lifecycle:
656+
RubyLLM provides two callback styles. The `on_*` handlers replace any previously registered handler for the same event, which is useful when you want to override behavior. The Rails-style `before_*` and `after_*` callbacks are additive, so multiple registrations for the same event all run. Additive callbacks are available from v1.15+.
657657

658658
```ruby
659659
chat = RubyLLM.chat
660660

661661
# Called at first chunk received from the assistant
662-
chat.on_new_message do
662+
chat.before_message do
663663
print "Assistant > "
664664
end
665665

666666
# Called after the complete assistant message (including tool calls/results) is received
667-
chat.on_end_message do |message|
667+
chat.after_message do |message|
668668
puts "Response complete!"
669669
# Note: message might be nil if an error occurred during the request
670670
if message && message.output_tokens
@@ -673,19 +673,21 @@ chat.on_end_message do |message|
673673
end
674674

675675
# Called when the AI decides to use a tool
676-
chat.on_tool_call do |tool_call|
676+
chat.before_tool_call do |tool_call|
677677
puts "AI is calling tool: #{tool_call.name} with arguments: #{tool_call.arguments}"
678678
end
679679

680680
# Called after a tool returns its result
681-
chat.on_tool_result do |result|
681+
chat.after_tool_result do |result|
682682
puts "Tool returned: #{result}"
683683
end
684684

685685
# These callbacks work for both streaming and non-streaming requests
686686
chat.ask "What is metaprogramming in Ruby?"
687687
```
688688

689+
The older `on_new_message`, `on_end_message`, `on_tool_call`, and `on_tool_result` handlers are still available and keep their replacing behavior. RubyLLM logs a deprecation warning when one of these handlers is used; prefer the additive Rails-style callbacks for new code.
690+
689691
## Raw Responses
690692

691693
You can access the raw response from the API provider with `response.raw`.

docs/_core_features/tools.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,17 +371,17 @@ This entire multi-step process happens behind the scenes within a single `chat.a
371371

372372
## Monitoring Tool Calls with Callbacks
373373

374-
You can monitor tool execution using event callbacks to track when tools are called and what they return:
374+
You can monitor tool execution using additive callbacks to track when tools are called and what they return. Available from v1.15+.
375375

376376
```ruby
377377
chat = RubyLLM.chat(model: '{{ site.models.openai_tools }}')
378378
.with_tool(Weather)
379-
.on_tool_call do |tool_call|
379+
.before_tool_call do |tool_call|
380380
# Called when the AI decides to use a tool
381381
puts "Calling tool: #{tool_call.name}"
382382
puts "Arguments: #{tool_call.arguments}"
383383
end
384-
.on_tool_result do |result|
384+
.after_tool_result do |result|
385385
# Called after the tool returns its result
386386
puts "Tool returned: #{result}"
387387
end
@@ -410,7 +410,7 @@ max_calls = 10
410410

411411
chat = RubyLLM.chat(model: '{{ site.models.openai_tools }}')
412412
.with_tool(Weather)
413-
.on_tool_call do |tool_call|
413+
.before_tool_call do |tool_call|
414414
call_count += 1
415415
if call_count > max_calls
416416
raise "Tool call limit exceeded (#{max_calls} calls)"
@@ -421,7 +421,7 @@ chat = RubyLLM.chat(model: '{{ site.models.openai_tools }}')
421421
chat.ask("Check weather for every major city...")
422422
```
423423

424-
> Raising an exception in `on_tool_call` breaks the conversation flow - the LLM expects a tool response after requesting a tool call. This can leave the chat in an inconsistent state. Consider using better models or clearer tool descriptions to prevent loops instead of hard limits.
424+
> Raising an exception in `before_tool_call` breaks the conversation flow - the LLM expects a tool response after requesting a tool call. This can leave the chat in an inconsistent state. Consider using better models or clearer tool descriptions to prevent loops instead of hard limits.
425425
{: .warning }
426426

427427
## Advanced Tool Metadata

lib/ruby_llm/active_record/acts_as_legacy.rb

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -160,27 +160,33 @@ def with_schema(...)
160160
self
161161
end
162162

163-
def on_new_message(&block)
164-
to_llm
163+
def on_new_message(&)
164+
to_llm.on_new_message(&)
165+
self
166+
end
165167

166-
existing_callback = @chat.instance_variable_get(:@on)[:new_message]
168+
def on_end_message(&)
169+
to_llm.on_end_message(&)
170+
self
171+
end
167172

168-
@chat.on_new_message do
169-
existing_callback&.call
170-
block&.call
171-
end
173+
def before_message(...)
174+
to_llm.before_message(...)
172175
self
173176
end
174177

175-
def on_end_message(&block)
176-
to_llm
178+
def after_message(...)
179+
to_llm.after_message(...)
180+
self
181+
end
177182

178-
existing_callback = @chat.instance_variable_get(:@on)[:end_message]
183+
def before_tool_call(...)
184+
to_llm.before_tool_call(...)
185+
self
186+
end
179187

180-
@chat.on_end_message do |msg|
181-
existing_callback&.call(msg)
182-
block&.call(msg)
183-
end
188+
def after_tool_result(...)
189+
to_llm.after_tool_result(...)
184190
self
185191
end
186192

@@ -319,8 +325,8 @@ def reapply_runtime_instructions(chat)
319325
def setup_persistence_callbacks
320326
return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
321327

322-
@chat.on_new_message { persist_new_message }
323-
@chat.on_end_message { |msg| persist_message_completion(msg) }
328+
@chat.before_message { persist_new_message }
329+
@chat.after_message { |msg| persist_message_completion(msg) }
324330

325331
@chat.instance_variable_set(:@_persistence_callbacks_setup, true)
326332
@chat

lib/ruby_llm/active_record/chat_methods.rb

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -154,27 +154,33 @@ def with_schema(...)
154154
self
155155
end
156156

157-
def on_new_message(&block)
158-
to_llm
157+
def on_new_message(&)
158+
to_llm.on_new_message(&)
159+
self
160+
end
159161

160-
existing_callback = @chat.instance_variable_get(:@on)[:new_message]
162+
def on_end_message(&)
163+
to_llm.on_end_message(&)
164+
self
165+
end
161166

162-
@chat.on_new_message do
163-
existing_callback&.call
164-
block&.call
165-
end
167+
def before_message(...)
168+
to_llm.before_message(...)
166169
self
167170
end
168171

169-
def on_end_message(&block)
170-
to_llm
172+
def after_message(...)
173+
to_llm.after_message(...)
174+
self
175+
end
171176

172-
existing_callback = @chat.instance_variable_get(:@on)[:end_message]
177+
def before_tool_call(...)
178+
to_llm.before_tool_call(...)
179+
self
180+
end
173181

174-
@chat.on_end_message do |msg|
175-
existing_callback&.call(msg)
176-
block&.call(msg)
177-
end
182+
def after_tool_result(...)
183+
to_llm.after_tool_result(...)
178184
self
179185
end
180186

@@ -258,8 +264,8 @@ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
258264
def setup_persistence_callbacks
259265
return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
260266

261-
@chat.on_new_message { persist_new_message }
262-
@chat.on_end_message { |msg| persist_message_completion(msg) }
267+
@chat.before_message { persist_new_message }
268+
@chat.after_message { |msg| persist_message_completion(msg) }
263269

264270
@chat.instance_variable_set(:@_persistence_callbacks_setup, true)
265271
@chat

lib/ruby_llm/agent.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ def initialize(chat: nil, inputs: nil, persist_instructions: true, **kwargs)
359359

360360
def_delegators :chat, :model, :messages, :tools, :params, :headers, :schema, :ask, :say, :with_tool, :with_tools,
361361
:with_model, :with_temperature, :with_thinking, :with_context, :with_params, :with_headers,
362-
:with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each, :complete,
363-
:add_message, :reset_messages!
362+
:with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :before_message,
363+
:after_message, :before_tool_call, :after_tool_result, :each, :complete, :add_message,
364+
:reset_messages!
364365
end
365366
end

lib/ruby_llm/chat.rb

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
3030
tool_call: nil,
3131
tool_result: nil
3232
}
33+
@callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
3334
end
3435

3536
def ask(message = nil, with: nil, &)
@@ -112,31 +113,43 @@ def with_schema(schema)
112113
self
113114
end
114115

115-
def on_new_message(&block)
116-
@on[:new_message] = block
117-
self
116+
def on_new_message(&)
117+
set_legacy_callback(:new_message, :on_new_message, :before_message, &)
118118
end
119119

120-
def on_end_message(&block)
121-
@on[:end_message] = block
122-
self
120+
def on_end_message(&)
121+
set_legacy_callback(:end_message, :on_end_message, :after_message, &)
123122
end
124123

125-
def on_tool_call(&block)
126-
@on[:tool_call] = block
127-
self
124+
def on_tool_call(&)
125+
set_legacy_callback(:tool_call, :on_tool_call, :before_tool_call, &)
128126
end
129127

130-
def on_tool_result(&block)
131-
@on[:tool_result] = block
132-
self
128+
def on_tool_result(&)
129+
set_legacy_callback(:tool_result, :on_tool_result, :after_tool_result, &)
130+
end
131+
132+
def before_message(&)
133+
add_callback(:before_message, &)
134+
end
135+
136+
def after_message(&)
137+
add_callback(:after_message, &)
138+
end
139+
140+
def before_tool_call(&)
141+
add_callback(:before_tool_call, &)
142+
end
143+
144+
def after_tool_result(&)
145+
add_callback(:after_tool_result, &)
133146
end
134147

135148
def each(&)
136149
messages.each(&)
137150
end
138151

139-
def complete(&) # rubocop:disable Metrics/PerceivedComplexity
152+
def complete(&)
140153
response = @provider.complete(
141154
messages,
142155
tools: @tools,
@@ -150,7 +163,7 @@ def complete(&) # rubocop:disable Metrics/PerceivedComplexity
150163
&wrap_streaming_block(&)
151164
)
152165

153-
@on[:new_message]&.call unless block_given?
166+
run_callbacks(:before_message, :new_message) unless block_given?
154167

155168
if @schema && response.content.is_a?(String) && !response.tool_call?
156169
begin
@@ -161,7 +174,7 @@ def complete(&) # rubocop:disable Metrics/PerceivedComplexity
161174
end
162175

163176
add_message response
164-
@on[:end_message]&.call(response)
177+
run_callbacks(:after_message, :end_message, response)
165178

166179
if response.tool_call?
167180
handle_tool_calls(response, &)
@@ -221,28 +234,52 @@ def sanitize_schema_name(name)
221234
sanitized.empty? ? 'response' : sanitized
222235
end
223236

237+
def add_callback(name, &block)
238+
@callbacks[name] << block if block
239+
self
240+
end
241+
242+
def set_legacy_callback(name, legacy_name, additive_name, &block)
243+
warn_legacy_callback_deprecation(legacy_name, additive_name) if block
244+
245+
@on[name] = block
246+
self
247+
end
248+
249+
def warn_legacy_callback_deprecation(legacy_name, additive_name)
250+
RubyLLM.logger.warn(
251+
"`#{legacy_name}` is deprecated and will be removed in RubyLLM 2.0. " \
252+
"Use `#{additive_name}` instead."
253+
)
254+
end
255+
256+
def run_callbacks(name, legacy_name, *args)
257+
@callbacks[name].each { |callback| callback.call(*args) }
258+
@on[legacy_name]&.call(*args)
259+
end
260+
224261
def wrap_streaming_block(&block)
225262
return nil unless block_given?
226263

227-
@on[:new_message]&.call
264+
run_callbacks(:before_message, :new_message)
228265

229266
proc do |chunk|
230267
block.call chunk
231268
end
232269
end
233270

234-
def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
271+
def handle_tool_calls(response, &)
235272
halt_result = nil
236273

237274
response.tool_calls.each_value do |tool_call|
238-
@on[:new_message]&.call
239-
@on[:tool_call]&.call(tool_call)
275+
run_callbacks(:before_message, :new_message)
276+
run_callbacks(:before_tool_call, :tool_call, tool_call)
240277
result = execute_tool tool_call
241-
@on[:tool_result]&.call(result)
278+
run_callbacks(:after_tool_result, :tool_result, result)
242279
tool_payload = result.is_a?(Tool::Halt) ? result.content : result
243280
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
244281
message = add_message role: :tool, content:, tool_call_id: tool_call.id
245-
@on[:end_message]&.call(message)
282+
run_callbacks(:after_message, :end_message, message)
246283

247284
halt_result = result if result.is_a?(Tool::Halt)
248285
end

0 commit comments

Comments
 (0)