From ea0c33a5976a9cb707ebc10124f8699357578892 Mon Sep 17 00:00:00 2001 From: Daniel Pereira de Souza Date: Fri, 16 Jan 2026 08:46:27 -0300 Subject: [PATCH 1/2] feat: Allow instance-level customization of tool parameters Add support for customizing tool description, parameters, and provider_params at the instance level, with fallback to class-level definitions when not overridden. - Add initialize method with optional description, parameters, and provider_params arguments - Modify instance getters to check instance values first, then fall back to class-level definitions - Add instance setters for post-initialization customization - Update params_schema to generate schema from instance parameters - Add comprehensive unit tests for instance-level customization - Add integration tests for customized tools with Chat Closes #418 --- lib/ruby_llm/tool.rb | 37 +++++++- spec/ruby_llm/chat_tools_spec.rb | 58 ++++++++++++ spec/ruby_llm/tool_spec.rb | 157 +++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 3 deletions(-) diff --git a/lib/ruby_llm/tool.rb b/lib/ruby_llm/tool.rb index 24d2468c7..3c1062433 100644 --- a/lib/ruby_llm/tool.rb +++ b/lib/ruby_llm/tool.rb @@ -62,6 +62,16 @@ def provider_params end end + # Initialize with optional instance-level overrides + # @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(description: nil, parameters: nil, provider_params: nil) + @instance_description = description + @instance_parameters = parameters + @instance_provider_params = provider_params + end + def name klass_name = self.class.name normalized = klass_name.to_s.dup.force_encoding('UTF-8').unicode_normalize(:nfkd) @@ -74,18 +84,39 @@ 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 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..f5997f11e 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -624,4 +624,62 @@ 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 '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..57b6c09c2 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)) @@ -41,4 +53,149 @@ expect(tool_class.new.name).to eq('sample') end end + + describe 'instance-level customization' do + describe '#initialize' do + 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( + description: 'Custom description', + parameters: custom_params, + provider_params: { timeout: 30 } + ) + + 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 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 From c2ff959e3c6293441194a032e0d9b2922784ed22 Mon Sep 17 00:00:00 2001 From: Daniel Pereira de Souza Date: Fri, 16 Jan 2026 09:11:02 -0300 Subject: [PATCH 2/2] feat: Add name: parameter for instance-level tool name override Allow users to explicitly set unique names for tool instances, solving the name collision issue where multiple instances of the same tool class would overwrite each other in the chat's tools hash. - Add name: parameter to Tool#initialize - Update name getter to check @instance_name first - Add name= setter for post-initialization customization - Add unit and integration tests - Document instance-level customization in tools.md --- docs/_core_features/tools.md | 52 ++++++++++++++++++++++++++++++++ lib/ruby_llm/tool.rb | 10 +++++- spec/ruby_llm/chat_tools_spec.rb | 11 +++++++ spec/ruby_llm/tool_spec.rb | 31 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) 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 3c1062433..e125c649e 100644 --- a/lib/ruby_llm/tool.rb +++ b/lib/ruby_llm/tool.rb @@ -63,16 +63,20 @@ def provider_params 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(description: nil, parameters: nil, provider_params: nil) + 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: '') @@ -96,6 +100,10 @@ def provider_params end # Instance-level setters for customization after initialization + def name=(value) + @instance_name = value + end + def description=(value) @instance_description = value end diff --git a/spec/ruby_llm/chat_tools_spec.rb b/spec/ruby_llm/chat_tools_spec.rb index f5997f11e..ecba61bbb 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -654,6 +654,17 @@ def tool_result_message_for(chat, tool_call) 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' } }) diff --git a/spec/ruby_llm/tool_spec.rb b/spec/ruby_llm/tool_spec.rb index 57b6c09c2..393ab3cb4 100644 --- a/spec/ruby_llm/tool_spec.rb +++ b/spec/ruby_llm/tool_spec.rb @@ -52,10 +52,33 @@ def execute(latitude:, longitude:) 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') @@ -79,11 +102,13 @@ def execute(latitude:, longitude:) 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 }) @@ -109,6 +134,12 @@ def execute(latitude:, longitude:) 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'