diff --git a/docs/guides/chat.md b/docs/guides/chat.md index 9f5fdfc0d..d2022a596 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)