Skip to content

Support Complex Parameter Schemas #124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ build-iPhoneSimulator/
# for a library or gem, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
Gemfile.lock
# .ruby-version
# .ruby-gemset
.ruby-version
.ruby-gemset

# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
.rvmrc
Expand All @@ -57,3 +57,4 @@ Gemfile.lock
# .rubocop-https?--*

repomix-output.*
/.idea/
56 changes: 45 additions & 11 deletions docs/guides/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,60 @@ class Weather < RubyLLM::Tool
param :longitude, desc: "Longitude (e.g., 13.4050)"

def execute(latitude:, longitude:)
puts "Requested weather for #{location[:city]}, #{location[:country]}" if location
puts "Use unit: #{unit}" if unit

url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"

response = Faraday.get(url)
data = JSON.parse(response.body)
JSON.parse(response.body)
rescue => e
{ error: e.message }
end
end
```

### Tool Components
### 1. Inherit from `RubyLLM::Tool`.
**Inheritance:** Must inherit from `RubyLLM::Tool`.
### 2. Use the **`description`** to describe the tool.
Use the **`description`** class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise.

### 3. Define params with **`param`**
Define parameters with **`param`** class method.

* The first param is always the name of the parameter.
* The remaining arguments are a hash representing the JSON schema of the parameter.
* Note 1: The root `required:` option isn't included in the schema.
* It specifies whether the AI *must* provide this parameter when calling the tool.
* It defaults to `true` (required)
* Set to `false` for optional parameters and provide a default value in your `execute` method signature.
* Note 2: `desc:` is automatically converted to `description:` in the schema.
* Only root level `desc:` is supported.
* If using more complex schemas, use `description:` instead.

#### Short form example:
Define simple parameters with the shorthand format:
```ruby
param :latitude, desc: "Latitude (e.g., 52.5200)"
```

#### More complex examples:
More complex JSON schemas are supported simply by defining them here as well:
```ruby
param :longitude, type: 'number', description: "Longitude (e.g., 13.4050)", required: true

param :unit, type: :string, enum: %w[f c], description: "Temperature unit (e.g., celsius, fahrenheit)", required: false

param :location, type: :object, desc: "Country and city where weather is requested.", required: false, properties: {
country: { type: :string, description: "Full name of the country." },
city: { type: :string, description: "Full name of the city." }
}
```
Remember, `required:` at the root is not part of the JSON schema and automatically removed before sending to the LLM API.
Also, `desc` at the root level is converted to `description`.

1. **Inheritance:** Must inherit from `RubyLLM::Tool`.
2. **`description`:** A class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise.
3. **`param`:** A class method used to define each input parameter.
* **Name:** The first argument (a symbol) is the parameter name. It will become a keyword argument in the `execute` method.
* **`type:`:** (Optional, defaults to `:string`) The expected data type. Common types include `:string`, `:integer`, `:number` (float), `:boolean`. Provider support for complex types like `:array` or `:object` varies. Stick to simple types for broad compatibility.
* **`desc:`:** (Required) A clear description of the parameter, explaining its purpose and expected format (e.g., "The city and state, e.g., San Francisco, CA").
* **`required:`:** (Optional, defaults to `true`) Whether the AI *must* provide this parameter when calling the tool. Set to `false` for optional parameters and provide a default value in your `execute` method signature.
4. **`execute` Method:** The instance method containing your Ruby code. It receives the parameters defined by `param` as keyword arguments. Its return value (typically a String or Hash) is sent back to the AI model.
### 3. Define `execute` method
Define the `execute` instance method containing your Ruby code. It receives the parameters defined by `param` as keyword arguments. Its return value (typically a String or Hash) is sent back to the AI model.

{: .note }
The tool's class name is automatically converted to a snake_case name used in the API call (e.g., `WeatherLookup` becomes `weather_lookup`).
Expand Down Expand Up @@ -210,4 +244,4 @@ Treat any arguments passed to your `execute` method as potentially untrusted use
* [Chatting with AI Models]({% link guides/chat.md %})
* [Streaming Responses]({% link guides/streaming.md %}) (See how tools interact with streaming)
* [Rails Integration]({% link guides/rails.md %}) (Persisting tool calls and results)
* [Error Handling]({% link guides/error-handling.md %})
* [Error Handling]({% link guides/error-handling.md %})
11 changes: 1 addition & 10 deletions lib/ruby_llm/providers/anthropic/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def function_for(tool)
description: tool.description,
input_schema: {
type: 'object',
properties: clean_parameters(tool.parameters),
properties: tool.parameters.transform_values(&:schema),
required: required_parameters(tool.parameters)
}
}
Expand All @@ -79,15 +79,6 @@ def parse_tool_calls(content_block)
}
end

def clean_parameters(parameters)
parameters.transform_values do |param|
{
type: param.type,
description: param.description
}.compact
end
end

def required_parameters(parameters)
parameters.select { |_, param| param.required }.keys
end
Expand Down
32 changes: 28 additions & 4 deletions lib/ruby_llm/providers/gemini/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,43 @@ def function_declaration_for(tool)
end

# Format tool parameters for Gemini API
# @param parameters [Hash{Symbol => RubyLLM::Parameter}]
# @return [Hash{String => String|Array|Hash|Boolean|NilClass}]
def format_parameters(parameters)
{
type: 'OBJECT',
properties: parameters.transform_values do |param|
{
type: param_type_for_gemini(param.type),
description: param.description
}.compact
convert_gemini_types(param.schema)
end,
required: parameters.select { |_, p| p.required }.keys.map(&:to_s)
}
end

##
# Convert JSON schema types to Gemini API types
# @param schema [Hash]
def convert_gemini_types(schema)
schema = schema.dup

schema['type'] = param_type_for_gemini(schema['type']) if schema.key?('type')
schema[:type] = param_type_for_gemini(schema[:type]) if schema.key?(:type)

schema.transform_values { |schema_value| convert_schema_value(schema_value) }
end

##
# Convert schema values to Gemini API types
# @param schema_value [String|Array|Hash|Boolean|NilClass]
def convert_schema_value(schema_value)
if schema_value.is_a?(Hash)
convert_gemini_types(schema_value.to_h)
elsif schema_value.is_a?(Array)
schema_value.map { |v| convert_gemini_types(v) }
else
schema_value
end
end

# Convert RubyLLM param types to Gemini API types
def param_type_for_gemini(type)
case type.to_s.downcase
Expand Down
9 changes: 1 addition & 8 deletions lib/ruby_llm/providers/openai/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,13 @@ def tool_for(tool) # rubocop:disable Metrics/MethodLength
description: tool.description,
parameters: {
type: 'object',
properties: tool.parameters.transform_values { |param| param_schema(param) },
properties: tool.parameters.transform_values(&:schema),
required: tool.parameters.select { |_, p| p.required }.keys
}
}
}
end

def param_schema(param)
{
type: param.type,
description: param.description
}.compact
end

def format_tool_calls(tool_calls) # rubocop:disable Metrics/MethodLength
return nil unless tool_calls&.any?

Expand Down
78 changes: 78 additions & 0 deletions lib/ruby_llm/schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

module RubyLLM
##
# Schema class for defining the structure of data objects.
# Wraps the #Hash class
# @see #Hash
class Schema
delegate_missing_to :@schema

##
# @param schema [Hash]
def initialize(schema = {})
@schema = deep_transform_keys_in_object(schema.to_h.dup, &:to_sym)
end

def [](key)
@schema[key.to_sym]
end

def []=(key, new_value)
@schema[key.to_sym] = deep_transform_keys_in_object(new_value, &:to_sym)
end

# Adds the new_value into the new_key key for every sub-schema that is of type: :object
# @param new_key [Symbol] The key to add to each object type.
# @param new_value [Boolean, String] The value to assign to the new key.
def add_to_each_object_type!(new_key, new_value)
add_to_each_object_type(new_key, new_value, @schema)
end

# @return [Boolean]
def present?
@schema.present? && @schema[:type].present?
end

private

def add_to_each_object_type(new_key, new_value, schema)
return schema unless schema.is_a?(Hash)

if schema[:type].to_s == :object.to_s
add_to_object_type(new_key, new_value, schema)
elsif schema[:type].to_s == :array.to_s && schema[:items]
schema[:items] = add_to_each_object_type(new_key, new_value, schema[:items])
end

schema
end

def add_to_object_type(new_key, new_value, schema)
if schema[new_key.to_sym].nil?
schema[new_key.to_sym] = new_value.is_a?(Proc) ? new_value.call(schema) : new_value
end

schema[:properties]&.transform_values! { |value| add_to_each_object_type(new_key, new_value, value) }
end

##
# Recursively transforms keys in a hash or array to symbols.
# Borrowed from ActiveSupport's Hash#deep_transform_keys
# @param object [Object] The object to transform.
# @param block [Proc] The block to apply to each key.
# @return [Object] The transformed object.
def deep_transform_keys_in_object(object, &block)
case object
when Hash
object.each_with_object({}) do |(key, value), result|
result[yield(key)] = deep_transform_keys_in_object(value, &block)
end
when Array
object.map { |e| deep_transform_keys_in_object(e, &block) }
else
object
end
end
end
end
53 changes: 47 additions & 6 deletions lib/ruby_llm/tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,40 @@
module RubyLLM
# Parameter definition for Tool methods. Specifies type constraints,
# descriptions, and whether parameters are required.
#
# @!attribute name [r]
# @return [Symbol]
# @!attribute required [r]
# @return [Boolean]
# @!attribute schema [r]
# @return [RubyLLM::Schema]
class Parameter
attr_reader :name, :type, :description, :required
attr_reader :name, :required, :schema

def initialize(name, type: 'string', desc: nil, required: true)
# If providing schema directly MAKE SURE TO USE STRING KEYS.
# Also note that under_scored keys are NOT automatically transformed to camelCase.
# @param name [Symbol]
# @param schema [Hash{String|Symbol => String|Array|Hash|Boolean|NilClass}, NilClass]
def initialize(name, required: true, **schema)
@name = name
@type = type
@description = desc
@required = required

@schema = Schema.new(schema)
@schema[:description] ||= @schema.delete(:desc) if @schema.key?(:desc)
@schema[:type] ||= :string
end

# @return [String]
def type
@schema[:type]
end

# @return [String, NilClass]
def description
@schema[:description]
end

alias required? required
end

# Base class for creating tools that AI models can use. Provides a simple
Expand Down Expand Up @@ -39,8 +64,24 @@ def description(text = nil)
@description = text
end

def param(name, **options)
parameters[name] = Parameter.new(name, **options)
# Define a parameter for the tool.
# Examples:
# ```ruby
# param :latitude, desc: "Latitude (e.g., 52.5200)" # Shorthand format
#
# param :longitude, type: 'number', description: "Longitude (e.g., 13.4050)", required: false # Longer format
#
# param :unit, type: :string, enum: %w[f c], description: "Temperature unit (e.g., celsius, fahrenheit)"
#
# param :location, type: :object, desc: "Country and city where weather is requested.", properties: {
# country: { type: :string, description: "Full name of the country." },
# city: { type: :string, description: "Full name of the city." }
# }
# ```
# @param name [Symbol]
# @param schema [Hash{String|Symbol => String|Numeric|Boolean|Hash|Array|NilClass}, NilClass]
def param(name, required: true, **schema)
parameters[name] = Parameter.new(name, required: required, **schema)
end

def parameters
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module RubyLLM
VERSION = '1.2.0'
VERSION = '1.3.0'
end
Loading