diff --git a/docs/_core_features/tools.md b/docs/_core_features/tools.md index b8c9d1388..2bc958325 100644 --- a/docs/_core_features/tools.md +++ b/docs/_core_features/tools.md @@ -249,6 +249,58 @@ search_tool = DocumentSearch.new(MyDatabase) chat.with_tool(search_tool) ``` +## Instance-Level Customization + +Tools can be customized at the instance level, overriding class-level definitions: + +### Customizing Description and Parameters + +```ruby +# Different descriptions for different contexts +berlin_weather = Weather.new(description: "Get current weather in Berlin") +paris_weather = Weather.new(description: "Get current weather in Paris") +``` + +### Using Unique Names for Multiple Instances + +When using multiple instances of the same tool class, provide unique names to avoid collisions: + +```ruby +berlin_tool = Weather.new( + name: 'berlin_weather', + description: 'Get Berlin weather' +) +paris_tool = Weather.new( + name: 'paris_weather', + description: 'Get Paris weather' +) + +chat = RubyLLM.chat.with_tools(berlin_tool, paris_tool) +# Both tools available: chat.tools[:berlin_weather] and chat.tools[:paris_weather] +``` + +> Without unique names, multiple instances of the same class will override each other since tools are stored by name. Always provide a `name:` when using multiple instances. +{: .warning } + +### Available Customization Options + +| Parameter | Description | +|-----------|-------------| +| `name:` | Custom tool name (default: derived from class name) | +| `description:` | Tool description for the AI model | +| `parameters:` | Hash of `Parameter` objects defining inputs | +| `provider_params:` | Provider-specific metadata (e.g., `cache_control`) | + +### Post-Initialization Customization + +All options can also be set after initialization: + +```ruby +tool = Weather.new +tool.name = 'custom_weather' +tool.description = 'Custom description' +``` + ## Using Tools in Chat Attach tools to a `Chat` instance using `with_tool` or `with_tools`. diff --git a/lib/ruby_llm/tool.rb b/lib/ruby_llm/tool.rb index 24d2468c7..e125c649e 100644 --- a/lib/ruby_llm/tool.rb +++ b/lib/ruby_llm/tool.rb @@ -62,7 +62,21 @@ def provider_params end end + # Initialize with optional instance-level overrides + # @param name [String, nil] Override the computed tool name (useful for unique identification) + # @param description [String, nil] Override the class-level description + # @param parameters [Hash, nil] Override the class-level parameters + # @param provider_params [Hash, nil] Override the class-level provider_params + def initialize(name: nil, description: nil, parameters: nil, provider_params: nil) + @instance_name = name + @instance_description = description + @instance_parameters = parameters + @instance_provider_params = provider_params + end + def name + return @instance_name if @instance_name + klass_name = self.class.name normalized = klass_name.to_s.dup.force_encoding('UTF-8').unicode_normalize(:nfkd) normalized.encode('ASCII', replace: '') @@ -74,18 +88,43 @@ def name end def description - self.class.description + @instance_description || self.class.description end def parameters - self.class.parameters + @instance_parameters || self.class.parameters end def provider_params - self.class.provider_params + @instance_provider_params || self.class.provider_params + end + + # Instance-level setters for customization after initialization + def name=(value) + @instance_name = value + end + + def description=(value) + @instance_description = value + end + + def parameters=(value) + @instance_parameters = value + end + + def provider_params=(value) + @instance_provider_params = value end def params_schema + # If instance parameters are set, generate schema from them (not memoized) + if @instance_parameters + return SchemaDefinition.from_parameters(@instance_parameters)&.json_schema if @instance_parameters.any? + + return nil + end + + # Otherwise use class-level schema (memoized) return @params_schema if defined?(@params_schema) @params_schema = begin diff --git a/spec/ruby_llm/chat_tools_spec.rb b/spec/ruby_llm/chat_tools_spec.rb index 385cef2bb..ecba61bbb 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -624,4 +624,73 @@ def tool_result_message_for(chat, tool_call) end end end + + describe 'instance-level tool customization' do + it 'uses instance-customized tool with chat' do + custom_tool = Weather.new(description: 'Gets weather for Berlin only') + + chat = RubyLLM.chat.with_tool(custom_tool) + tool_instance = chat.tools[:weather] + + expect(tool_instance.description).to eq('Gets weather for Berlin only') + end + + it 'allows multiple customized instances of the same tool class' do + berlin_params = { + district: RubyLLM::Parameter.new(:district, type: 'string', desc: 'Berlin district', required: true) + } + + berlin_tool = Weather.new( + description: 'Gets Berlin weather by district' + ) + paris_tool = Weather.new( + description: 'Gets Paris weather by arrondissement' + ) + + chat = RubyLLM.chat.with_tools(berlin_tool, paris_tool) + + # Note: Both tools have the same name since they're the same class + # The second one will override the first in the tools hash + expect(chat.tools[:weather].description).to eq('Gets Paris weather by arrondissement') + end + + it 'allows multiple instances of the same class with unique names' do + berlin_tool = Weather.new(name: 'berlin_weather', description: 'Berlin weather') + paris_tool = Weather.new(name: 'paris_weather', description: 'Paris weather') + + chat = RubyLLM.chat.with_tools(berlin_tool, paris_tool) + + expect(chat.tools.keys).to contain_exactly(:berlin_weather, :paris_weather) + expect(chat.tools[:berlin_weather].description).to eq('Berlin weather') + expect(chat.tools[:paris_weather].description).to eq('Paris weather') + end + + it 'uses customized provider_params from instance' do + custom_tool = Weather.new(provider_params: { cache_control: { type: 'ephemeral' } }) + + chat = RubyLLM.chat.with_tool(custom_tool) + tool_instance = chat.tools[:weather] + + expect(tool_instance.provider_params).to eq({ cache_control: { type: 'ephemeral' } }) + end + + it 'preserves instance customization after adding to chat' do + custom_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'City name', required: true) + } + + custom_tool = Weather.new( + description: 'Custom weather tool', + parameters: custom_params, + provider_params: { timeout: 30 } + ) + + chat = RubyLLM.chat.with_tool(custom_tool) + tool_instance = chat.tools[:weather] + + expect(tool_instance.description).to eq('Custom weather tool') + expect(tool_instance.parameters).to eq(custom_params) + expect(tool_instance.provider_params).to eq({ timeout: 30 }) + end + end end diff --git a/spec/ruby_llm/tool_spec.rb b/spec/ruby_llm/tool_spec.rb index 37eb8bace..393ab3cb4 100644 --- a/spec/ruby_llm/tool_spec.rb +++ b/spec/ruby_llm/tool_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe RubyLLM::Tool do + # Helper tool class for testing instance-level customization + class TestWeatherTool < RubyLLM::Tool # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + description 'Gets current weather for a location' + param :latitude, desc: 'Latitude (e.g., 52.5200)' + param :longitude, desc: 'Longitude (e.g., 13.4050)' + with_params cache_control: { type: 'ephemeral' } + + def execute(latitude:, longitude:) + "Current weather at #{latitude}, #{longitude}: 15°C" + end + end + describe '#name' do it 'converts class name to snake_case and removes _tool suffix' do stub_const('SampleTool', Class.new(described_class)) @@ -40,5 +52,181 @@ allow(tool_class).to receive(:name).and_return(ascii_8bit_name) expect(tool_class.new.name).to eq('sample') end + + context 'with instance-level override' do + it 'uses instance name when provided in initialize' do + tool = TestWeatherTool.new(name: 'custom_weather') + expect(tool.name).to eq('custom_weather') + end + + it 'allows setting name after initialization' do + tool = TestWeatherTool.new + tool.name = 'renamed_weather' + expect(tool.name).to eq('renamed_weather') + end + + it 'falls back to computed name when not overridden' do + tool = TestWeatherTool.new + expect(tool.name).to eq('test_weather') + end + end + end + + describe 'instance-level customization' do + describe '#initialize' do + it 'accepts optional name parameter' do + tool = TestWeatherTool.new(name: 'custom_weather_name') + expect(tool.name).to eq('custom_weather_name') + end + + it 'accepts optional description parameter' do + tool = TestWeatherTool.new(description: 'Custom weather description') + expect(tool.description).to eq('Custom weather description') + end + + it 'accepts optional parameters parameter' do + custom_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'City name', required: true) + } + tool = TestWeatherTool.new(parameters: custom_params) + expect(tool.parameters).to eq(custom_params) + end + + it 'accepts optional provider_params parameter' do + tool = TestWeatherTool.new(provider_params: { timeout: 30 }) + expect(tool.provider_params).to eq({ timeout: 30 }) + end + + it 'accepts all optional parameters at once' do + custom_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'City name', required: true) + } + tool = TestWeatherTool.new( + name: 'custom_tool_name', + description: 'Custom description', + parameters: custom_params, + provider_params: { timeout: 30 } + ) + + expect(tool.name).to eq('custom_tool_name') + expect(tool.description).to eq('Custom description') + expect(tool.parameters).to eq(custom_params) + expect(tool.provider_params).to eq({ timeout: 30 }) + end + end + + describe 'fallback to class-level definitions' do + it 'falls back to class description when not overridden' do + tool = TestWeatherTool.new + expect(tool.description).to eq('Gets current weather for a location') + end + + it 'falls back to class parameters when not overridden' do + tool = TestWeatherTool.new + expect(tool.parameters).to eq(TestWeatherTool.parameters) + expect(tool.parameters.keys).to contain_exactly(:latitude, :longitude) + end + + it 'falls back to class provider_params when not overridden' do + tool = TestWeatherTool.new + expect(tool.provider_params).to eq({ cache_control: { type: 'ephemeral' } }) + end + end + + describe 'instance setters' do + it 'allows setting name after initialization' do + tool = TestWeatherTool.new + tool.name = 'updated_tool_name' + expect(tool.name).to eq('updated_tool_name') + end + + it 'allows setting description after initialization' do + tool = TestWeatherTool.new + tool.description = 'Updated description' + expect(tool.description).to eq('Updated description') + end + + it 'allows setting parameters after initialization' do + tool = TestWeatherTool.new + custom_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'City name', required: true) + } + tool.parameters = custom_params + expect(tool.parameters).to eq(custom_params) + end + + it 'allows setting provider_params after initialization' do + tool = TestWeatherTool.new + tool.provider_params = { max_retries: 3 } + expect(tool.provider_params).to eq({ max_retries: 3 }) + end + end + + describe '#params_schema with instance parameters' do + it 'generates schema from instance parameters' do + custom_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'City name', required: true), + country: RubyLLM::Parameter.new(:country, type: 'string', desc: 'Country code', required: false) + } + tool = TestWeatherTool.new(parameters: custom_params) + + schema = tool.params_schema + expect(schema['type']).to eq('object') + expect(schema['properties']).to have_key('city') + expect(schema['properties']).to have_key('country') + expect(schema['properties']['city']['description']).to eq('City name') + expect(schema['required']).to contain_exactly('city') + end + + it 'returns nil for empty instance parameters' do + tool = TestWeatherTool.new(parameters: {}) + expect(tool.params_schema).to be_nil + end + + it 'uses class-level schema when no instance parameters set' do + tool = TestWeatherTool.new + schema = tool.params_schema + + expect(schema['type']).to eq('object') + expect(schema['properties']).to have_key('latitude') + expect(schema['properties']).to have_key('longitude') + end + end + + describe 'multiple instances with different configurations' do + it 'allows different instances of the same class to have different descriptions' do + tool1 = TestWeatherTool.new(description: 'Get Berlin weather') + tool2 = TestWeatherTool.new(description: 'Get Paris weather') + tool3 = TestWeatherTool.new + + expect(tool1.description).to eq('Get Berlin weather') + expect(tool2.description).to eq('Get Paris weather') + expect(tool3.description).to eq('Gets current weather for a location') + end + + it 'allows different instances to have different parameters' do + berlin_params = { + city: RubyLLM::Parameter.new(:city, type: 'string', desc: 'Berlin district', required: true) + } + paris_params = { + arrondissement: RubyLLM::Parameter.new(:arrondissement, type: 'integer', desc: 'Paris district', required: true) + } + + tool1 = TestWeatherTool.new(parameters: berlin_params) + tool2 = TestWeatherTool.new(parameters: paris_params) + tool3 = TestWeatherTool.new + + expect(tool1.parameters.keys).to contain_exactly(:city) + expect(tool2.parameters.keys).to contain_exactly(:arrondissement) + expect(tool3.parameters.keys).to contain_exactly(:latitude, :longitude) + end + + it 'does not affect class-level definitions' do + tool = TestWeatherTool.new(description: 'Custom description') + + expect(tool.description).to eq('Custom description') + expect(TestWeatherTool.description).to eq('Gets current weather for a location') + end + end end end