From cccefcd674adb31b4c2f6b1e1cf62c246c77ae86 Mon Sep 17 00:00:00 2001 From: Bryan Ash Date: Sun, 27 Jul 2025 14:27:43 -0400 Subject: [PATCH 1/2] Autocorrect RuboCop violations --- lib/generators/ruby_llm/install_generator.rb | 1 + spec/dummy/app/models/chat.rb | 1 + spec/dummy/app/models/message.rb | 1 + spec/dummy/app/models/tool_call.rb | 1 + spec/ruby_llm/active_record/acts_as_spec.rb | 3 +++ 5 files changed, 7 insertions(+) diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 45682d753..c6cef9a22 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -7,6 +7,7 @@ module RubyLLM # Generator for RubyLLM Rails models and migrations class InstallGenerator < Rails::Generators::Base include Rails::Generators::Migration + namespace 'ruby_llm:install' source_root File.expand_path('install/templates', __dir__) diff --git a/spec/dummy/app/models/chat.rb b/spec/dummy/app/models/chat.rb index 7e675cfcc..668facaad 100644 --- a/spec/dummy/app/models/chat.rb +++ b/spec/dummy/app/models/chat.rb @@ -2,5 +2,6 @@ class Chat < ApplicationRecord include RubyLLM::ActiveRecord::ActsAs + acts_as_chat end diff --git a/spec/dummy/app/models/message.rb b/spec/dummy/app/models/message.rb index 9f6179ca8..de7a6d9f1 100644 --- a/spec/dummy/app/models/message.rb +++ b/spec/dummy/app/models/message.rb @@ -2,6 +2,7 @@ class Message < ApplicationRecord include RubyLLM::ActiveRecord::ActsAs + acts_as_message has_many_attached :attachments end diff --git a/spec/dummy/app/models/tool_call.rb b/spec/dummy/app/models/tool_call.rb index 134e749a7..00d1d41c9 100644 --- a/spec/dummy/app/models/tool_call.rb +++ b/spec/dummy/app/models/tool_call.rb @@ -2,5 +2,6 @@ class ToolCall < ApplicationRecord include RubyLLM::ActiveRecord::ActsAs + acts_as_tool_call end diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 89fe03e80..f69448bd2 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -192,17 +192,20 @@ module Assistants # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyCo class BotChat < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration self.table_name = 'bot_chats' include RubyLLM::ActiveRecord::ActsAs + acts_as_chat message_class: 'BotMessage', tool_call_class: 'BotToolCall' end end class BotMessage < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration include RubyLLM::ActiveRecord::ActsAs + acts_as_message chat_class: 'Assistants::BotChat', tool_call_class: 'BotToolCall' end class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration include RubyLLM::ActiveRecord::ActsAs + acts_as_tool_call message_class: 'BotMessage' end From bcce1d09a7d6bf8e774db4ebebbc7b283e9dc0fa Mon Sep 17 00:00:00 2001 From: Bryan Ash Date: Sun, 27 Jul 2025 14:23:54 -0400 Subject: [PATCH 2/2] Add Chat#on_tool_call callback This is useful when you want to show to the user tool calls as they are made, particularly when handling streamed output. --- docs/guides/chat.md | 11 +- lib/ruby_llm/chat.rb | 9 +- ...tool_call_callback_when_tools_are_used.yml | 209 ++++++++++++++++++ spec/ruby_llm/chat_tools_spec.rb | 19 ++ 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 spec/fixtures/vcr_cassettes/chat_tool_call_callbacks_calls_on_tool_call_callback_when_tools_are_used.yml diff --git a/docs/guides/chat.md b/docs/guides/chat.md index 06a413835..ed5401ee6 100644 --- a/docs/guides/chat.md +++ b/docs/guides/chat.md @@ -464,7 +464,11 @@ Refer to the [Working with Models Guide]({% link guides/models.md %}) for detail ## Chat Event Handlers -You can register blocks to be called when certain events occur during the chat lifecycle, useful for UI updates or logging. +You can register blocks to be called when certain events occur during the chat lifecycle. This is particularly useful for UI updates, logging, analytics, or building real-time chat interfaces. + +### Available Event Handlers + +RubyLLM provides three event handlers that cover the complete chat lifecycle: ```ruby chat = RubyLLM.chat @@ -483,6 +487,11 @@ chat.on_end_message do |message| end end +# Called when the AI decides to use a tool +chat.on_tool_call do |tool_call| + puts "AI is calling tool: #{tool_call.name} with arguments: #{tool_call.arguments}" +end + # These callbacks work for both streaming and non-streaming requests chat.ask "What is metaprogramming in Ruby?" ``` diff --git a/lib/ruby_llm/chat.rb b/lib/ruby_llm/chat.rb index 7d3f18ca1..fa6153c41 100644 --- a/lib/ruby_llm/chat.rb +++ b/lib/ruby_llm/chat.rb @@ -29,7 +29,8 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n @schema = nil @on = { new_message: nil, - end_message: nil + end_message: nil, + tool_call: nil } end @@ -112,6 +113,11 @@ def on_end_message(&block) self end + def on_tool_call(&block) + @on[:tool_call] = block + self + end + def each(&) messages.each(&) end @@ -181,6 +187,7 @@ def wrap_streaming_block(&block) def handle_tool_calls(response, &) response.tool_calls.each_value do |tool_call| @on[:new_message]&.call + @on[:tool_call]&.call(tool_call) result = execute_tool tool_call message = add_message role: :tool, content: result.to_s, tool_call_id: tool_call.id @on[:end_message]&.call(message) diff --git a/spec/fixtures/vcr_cassettes/chat_tool_call_callbacks_calls_on_tool_call_callback_when_tools_are_used.yml b/spec/fixtures/vcr_cassettes/chat_tool_call_callbacks_calls_on_tool_call_callback_when_tools_are_used.yml new file mode 100644 index 000000000..eff670917 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_tool_call_callbacks_calls_on_tool_call_callback_when_tools_are_used.yml @@ -0,0 +1,209 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"user","content":"What''s + the weather in Berlin? (52.5200, 13.4050)"}],"stream":false,"temperature":0.7,"tools":[{"type":"function","function":{"name":"weather","description":"Gets + current weather for a location","parameters":{"type":"object","properties":{"latitude":{"type":"string","description":"Latitude + (e.g., 52.5200)"},"longitude":{"type":"string","description":"Longitude (e.g., + 13.4050)"}},"required":["latitude","longitude"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.13.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 17 Jul 2025 09:59:10 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '295' + Openai-Project: + - proj_KyS64Yhsc9qhhwjNcgkOa88E + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '301' + X-Ratelimit-Limit-Requests: + - '500' + X-Ratelimit-Limit-Tokens: + - '200000' + X-Ratelimit-Remaining-Requests: + - '499' + X-Ratelimit-Remaining-Tokens: + - '199986' + X-Ratelimit-Reset-Requests: + - 120ms + X-Ratelimit-Reset-Tokens: + - 4ms + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BuFgMrnTJUENgtVwLWZvfeMhDMgUM", + "object": "chat.completion", + "created": 1752746350, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_iUSheTniJCAoDpKZKvhSvZ99", + "type": "function", + "function": { + "name": "weather", + "arguments": "{\"latitude\":\"52.5200\",\"longitude\":\"13.4050\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 88, + "completion_tokens": 23, + "total_tokens": 111, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + recorded_at: Thu, 17 Jul 2025 09:59:10 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"user","content":"What''s + the weather in Berlin? (52.5200, 13.4050)"},{"role":"assistant","tool_calls":[{"id":"call_iUSheTniJCAoDpKZKvhSvZ99","type":"function","function":{"name":"weather","arguments":"{\"latitude\":\"52.5200\",\"longitude\":\"13.4050\"}"}}]},{"role":"tool","content":"Current + weather at 52.5200, 13.4050: 15°C, Wind: 10 km/h","tool_call_id":"call_iUSheTniJCAoDpKZKvhSvZ99"}],"stream":false,"temperature":0.7,"tools":[{"type":"function","function":{"name":"weather","description":"Gets + current weather for a location","parameters":{"type":"object","properties":{"latitude":{"type":"string","description":"Latitude + (e.g., 52.5200)"},"longitude":{"type":"string","description":"Longitude (e.g., + 13.4050)"}},"required":["latitude","longitude"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.13.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Thu, 17 Jul 2025 09:59:11 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '289' + Openai-Project: + - proj_KyS64Yhsc9qhhwjNcgkOa88E + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '293' + X-Ratelimit-Limit-Requests: + - '500' + X-Ratelimit-Limit-Tokens: + - '200000' + X-Ratelimit-Remaining-Requests: + - '499' + X-Ratelimit-Remaining-Tokens: + - '199968' + X-Ratelimit-Reset-Requests: + - 120ms + X-Ratelimit-Reset-Tokens: + - 9ms + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: !binary |- + ewogICJpZCI6ICJjaGF0Y21wbC1CdUZnTjh0V2tyMllKYnZndVZXa3VUMk9NeFY1VyIsCiAgIm9iamVjdCI6ICJjaGF0LmNvbXBsZXRpb24iLAogICJjcmVhdGVkIjogMTc1Mjc0NjM1MSwKICAibW9kZWwiOiAiZ3B0LTQuMS1uYW5vLTIwMjUtMDQtMTQiLAogICJjaG9pY2VzIjogWwogICAgewogICAgICAiaW5kZXgiOiAwLAogICAgICAibWVzc2FnZSI6IHsKICAgICAgICAicm9sZSI6ICJhc3Npc3RhbnQiLAogICAgICAgICJjb250ZW50IjogIlRoZSBjdXJyZW50IHdlYXRoZXIgaW4gQmVybGluIGlzIDE1wrBDIHdpdGggYSB3aW5kIHNwZWVkIG9mIDEwIGttL2guIiwKICAgICAgICAicmVmdXNhbCI6IG51bGwsCiAgICAgICAgImFubm90YXRpb25zIjogW10KICAgICAgfSwKICAgICAgImxvZ3Byb2JzIjogbnVsbCwKICAgICAgImZpbmlzaF9yZWFzb24iOiAic3RvcCIKICAgIH0KICBdLAogICJ1c2FnZSI6IHsKICAgICJwcm9tcHRfdG9rZW5zIjogMTQzLAogICAgImNvbXBsZXRpb25fdG9rZW5zIjogMjAsCiAgICAidG90YWxfdG9rZW5zIjogMTYzLAogICAgInByb21wdF90b2tlbnNfZGV0YWlscyI6IHsKICAgICAgImNhY2hlZF90b2tlbnMiOiAwLAogICAgICAiYXVkaW9fdG9rZW5zIjogMAogICAgfSwKICAgICJjb21wbGV0aW9uX3Rva2Vuc19kZXRhaWxzIjogewogICAgICAicmVhc29uaW5nX3Rva2VucyI6IDAsCiAgICAgICJhdWRpb190b2tlbnMiOiAwLAogICAgICAiYWNjZXB0ZWRfcHJlZGljdGlvbl90b2tlbnMiOiAwLAogICAgICAicmVqZWN0ZWRfcHJlZGljdGlvbl90b2tlbnMiOiAwCiAgICB9CiAgfSwKICAic2VydmljZV90aWVyIjogImRlZmF1bHQiLAogICJzeXN0ZW1fZmluZ2VycHJpbnQiOiBudWxsCn0K + recorded_at: Thu, 17 Jul 2025 09:59:11 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/chat_tools_spec.rb b/spec/ruby_llm/chat_tools_spec.rb index a59f3cac8..43cf60cfd 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -172,6 +172,25 @@ def execute end end + describe 'tool call callbacks' do + it 'calls on_tool_call callback when tools are used' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + tool_calls_received = [] + + chat = RubyLLM.chat + .with_tool(Weather) + .on_tool_call { |tool_call| tool_calls_received << tool_call } + + response = chat.ask("What's the weather in Berlin? (52.5200, 13.4050)") + + expect(tool_calls_received).not_to be_empty + expect(tool_calls_received.first).to respond_to(:name) + expect(tool_calls_received.first).to respond_to(:arguments) + expect(tool_calls_received.first.name).to eq('weather') + expect(response.content).to include('15') + expect(response.content).to include('10') + end + end + describe 'error handling' do it 'raises an error when tool execution fails' do # rubocop:disable RSpec/MultipleExpectations chat = RubyLLM.chat.with_tool(BrokenTool)