Skip to content

Commit 9e03cd8

Browse files
committed
Adds the ability to define more complex param schemas, while retaining the existing simple methods + bump version to 1.3 since this is kind of a major feature.
1 parent a0f3704 commit 9e03cd8

21 files changed

+2099
-44
lines changed

.gitignore

+3-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ build-iPhoneSimulator/
4747
# for a library or gem, you might want to ignore these files since the code is
4848
# intended to run in multiple environments; otherwise, check them in:
4949
Gemfile.lock
50-
# .ruby-version
51-
# .ruby-gemset
50+
.ruby-version
51+
.ruby-gemset
5252

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

5959
repomix-output.*
60+
/.idea/

docs/guides/tools.md

+45-11
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,60 @@ class Weather < RubyLLM::Tool
5353
param :longitude, desc: "Longitude (e.g., 13.4050)"
5454

5555
def execute(latitude:, longitude:)
56+
puts "Requested weather for #{location[:city]}, #{location[:country]}" if location
57+
puts "Use unit: #{unit}" if unit
58+
5659
url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"
5760

5861
response = Faraday.get(url)
59-
data = JSON.parse(response.body)
62+
JSON.parse(response.body)
6063
rescue => e
6164
{ error: e.message }
6265
end
6366
end
6467
```
6568

66-
### Tool Components
69+
### 1. Inherit from `RubyLLM::Tool`.
70+
**Inheritance:** Must inherit from `RubyLLM::Tool`.
71+
### 2. Use the **`description`** to describe the tool.
72+
Use the **`description`** class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise.
73+
74+
### 3. Define params with **`param`**
75+
Define parameters with **`param`** class method.
76+
77+
* The first param is always the name of the parameter.
78+
* The remaining arguments are a hash representing the JSON schema of the parameter.
79+
* Note 1: The root `required:` option isn't included in the schema.
80+
* It specifies whether the AI *must* provide this parameter when calling the tool.
81+
* It defaults to `true` (required)
82+
* Set to `false` for optional parameters and provide a default value in your `execute` method signature.
83+
* Note 2: `desc:` is automatically converted to `description:` in the schema.
84+
* Only root level `desc:` is supported.
85+
* If using more complex schemas, use `description:` instead.
86+
87+
#### Short form example:
88+
Define simple parameters with the shorthand format:
89+
```ruby
90+
param :latitude, desc: "Latitude (e.g., 52.5200)"
91+
```
92+
93+
#### More complex examples:
94+
More complex JSON schemas are supported simply by defining them here as well:
95+
```ruby
96+
param :longitude, type: 'number', description: "Longitude (e.g., 13.4050)", required: true
97+
98+
param :unit, type: :string, enum: %w[f c], description: "Temperature unit (e.g., celsius, fahrenheit)", required: false
99+
100+
param :location, type: :object, desc: "Country and city where weather is requested.", required: false, properties: {
101+
country: { type: :string, description: "Full name of the country." },
102+
city: { type: :string, description: "Full name of the city." }
103+
}
104+
```
105+
Remember, `required:` at the root is not part of the JSON schema and automatically removed before sending to the LLM API.
106+
Also, `desc` at the root level is converted to `description`.
67107

68-
1. **Inheritance:** Must inherit from `RubyLLM::Tool`.
69-
2. **`description`:** A class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise.
70-
3. **`param`:** A class method used to define each input parameter.
71-
* **Name:** The first argument (a symbol) is the parameter name. It will become a keyword argument in the `execute` method.
72-
* **`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.
73-
* **`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").
74-
* **`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.
75-
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.
108+
### 3. Define `execute` method
109+
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.
76110

77111
{: .note }
78112
The tool's class name is automatically converted to a snake_case name used in the API call (e.g., `WeatherLookup` becomes `weather_lookup`).
@@ -210,4 +244,4 @@ Treat any arguments passed to your `execute` method as potentially untrusted use
210244
* [Chatting with AI Models]({% link guides/chat.md %})
211245
* [Streaming Responses]({% link guides/streaming.md %}) (See how tools interact with streaming)
212246
* [Rails Integration]({% link guides/rails.md %}) (Persisting tool calls and results)
213-
* [Error Handling]({% link guides/error-handling.md %})
247+
* [Error Handling]({% link guides/error-handling.md %})

lib/ruby_llm/providers/anthropic/tools.rb

+1-10
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def function_for(tool)
5353
description: tool.description,
5454
input_schema: {
5555
type: 'object',
56-
properties: clean_parameters(tool.parameters),
56+
properties: tool.parameters.transform_values(&:schema),
5757
required: required_parameters(tool.parameters)
5858
}
5959
}
@@ -79,15 +79,6 @@ def parse_tool_calls(content_block)
7979
}
8080
end
8181

82-
def clean_parameters(parameters)
83-
parameters.transform_values do |param|
84-
{
85-
type: param.type,
86-
description: param.description
87-
}.compact
88-
end
89-
end
90-
9182
def required_parameters(parameters)
9283
parameters.select { |_, param| param.required }.keys
9384
end

lib/ruby_llm/providers/gemini/tools.rb

+28-4
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,43 @@ def function_declaration_for(tool)
5959
end
6060

6161
# Format tool parameters for Gemini API
62+
# @param parameters [Hash{Symbol => RubyLLM::Parameter}]
63+
# @return [Hash{String => String|Array|Hash|Boolean|NilClass}]
6264
def format_parameters(parameters)
6365
{
6466
type: 'OBJECT',
6567
properties: parameters.transform_values do |param|
66-
{
67-
type: param_type_for_gemini(param.type),
68-
description: param.description
69-
}.compact
68+
convert_gemini_types(param.schema)
7069
end,
7170
required: parameters.select { |_, p| p.required }.keys.map(&:to_s)
7271
}
7372
end
7473

74+
##
75+
# Convert JSON schema types to Gemini API types
76+
# @param schema [Hash]
77+
def convert_gemini_types(schema)
78+
schema = schema.dup
79+
80+
schema['type'] = param_type_for_gemini(schema['type']) if schema.key?('type')
81+
schema[:type] = param_type_for_gemini(schema[:type]) if schema.key?(:type)
82+
83+
schema.transform_values { |schema_value| convert_schema_value(schema_value) }
84+
end
85+
86+
##
87+
# Convert schema values to Gemini API types
88+
# @param schema_value [String|Array|Hash|Boolean|NilClass]
89+
def convert_schema_value(schema_value)
90+
if schema_value.is_a?(Hash)
91+
convert_gemini_types(schema_value.to_h)
92+
elsif schema_value.is_a?(Array)
93+
schema_value.map { |v| convert_gemini_types(v) }
94+
else
95+
schema_value
96+
end
97+
end
98+
7599
# Convert RubyLLM param types to Gemini API types
76100
def param_type_for_gemini(type)
77101
case type.to_s.downcase

lib/ruby_llm/providers/openai/tools.rb

+1-8
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,13 @@ def tool_for(tool) # rubocop:disable Metrics/MethodLength
1515
description: tool.description,
1616
parameters: {
1717
type: 'object',
18-
properties: tool.parameters.transform_values { |param| param_schema(param) },
18+
properties: tool.parameters.transform_values(&:schema),
1919
required: tool.parameters.select { |_, p| p.required }.keys
2020
}
2121
}
2222
}
2323
end
2424

25-
def param_schema(param)
26-
{
27-
type: param.type,
28-
description: param.description
29-
}.compact
30-
end
31-
3225
def format_tool_calls(tool_calls) # rubocop:disable Metrics/MethodLength
3326
return nil unless tool_calls&.any?
3427

lib/ruby_llm/schema.rb

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
##
5+
# Schema class for defining the structure of data objects.
6+
# Wraps the #Hash class
7+
# @see #Hash
8+
class Schema
9+
delegate_missing_to :@schema
10+
11+
##
12+
# @param schema [Hash]
13+
def initialize(schema = {})
14+
@schema = deep_transform_keys_in_object(schema.to_h.dup, &:to_sym)
15+
end
16+
17+
def [](key)
18+
@schema[key.to_sym]
19+
end
20+
21+
def []=(key, new_value)
22+
@schema[key.to_sym] = deep_transform_keys_in_object(new_value, &:to_sym)
23+
end
24+
25+
# Adds the new_value into the new_key key for every sub-schema that is of type: :object
26+
# @param new_key [Symbol] The key to add to each object type.
27+
# @param new_value [Boolean, String] The value to assign to the new key.
28+
def add_to_each_object_type!(new_key, new_value)
29+
add_to_each_object_type(new_key, new_value, @schema)
30+
end
31+
32+
# @return [Boolean]
33+
def present?
34+
@schema.present? && @schema[:type].present?
35+
end
36+
37+
private
38+
39+
def add_to_each_object_type(new_key, new_value, schema)
40+
return schema unless schema.is_a?(Hash)
41+
42+
if schema[:type].to_s == :object.to_s
43+
add_to_object_type(new_key, new_value, schema)
44+
elsif schema[:type].to_s == :array.to_s && schema[:items]
45+
schema[:items] = add_to_each_object_type(new_key, new_value, schema[:items])
46+
end
47+
48+
schema
49+
end
50+
51+
def add_to_object_type(new_key, new_value, schema)
52+
if schema[new_key.to_sym].nil?
53+
schema[new_key.to_sym] = new_value.is_a?(Proc) ? new_value.call(schema) : new_value
54+
end
55+
56+
schema[:properties]&.transform_values! { |value| add_to_each_object_type(new_key, new_value, value) }
57+
end
58+
59+
##
60+
# Recursively transforms keys in a hash or array to symbols.
61+
# Borrowed from ActiveSupport's Hash#deep_transform_keys
62+
# @param object [Object] The object to transform.
63+
# @param block [Proc] The block to apply to each key.
64+
# @return [Object] The transformed object.
65+
def deep_transform_keys_in_object(object, &block)
66+
case object
67+
when Hash
68+
object.each_with_object({}) do |(key, value), result|
69+
result[yield(key)] = deep_transform_keys_in_object(value, &block)
70+
end
71+
when Array
72+
object.map { |e| deep_transform_keys_in_object(e, &block) }
73+
else
74+
object
75+
end
76+
end
77+
end
78+
end

lib/ruby_llm/tool.rb

+47-6
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,40 @@
33
module RubyLLM
44
# Parameter definition for Tool methods. Specifies type constraints,
55
# descriptions, and whether parameters are required.
6+
#
7+
# @!attribute name [r]
8+
# @return [Symbol]
9+
# @!attribute required [r]
10+
# @return [Boolean]
11+
# @!attribute schema [r]
12+
# @return [RubyLLM::Schema]
613
class Parameter
7-
attr_reader :name, :type, :description, :required
14+
attr_reader :name, :required, :schema
815

9-
def initialize(name, type: 'string', desc: nil, required: true)
16+
# If providing schema directly MAKE SURE TO USE STRING KEYS.
17+
# Also note that under_scored keys are NOT automatically transformed to camelCase.
18+
# @param name [Symbol]
19+
# @param schema [Hash{String|Symbol => String|Array|Hash|Boolean|NilClass}, NilClass]
20+
def initialize(name, required: true, **schema)
1021
@name = name
11-
@type = type
12-
@description = desc
1322
@required = required
23+
24+
@schema = Schema.new(schema)
25+
@schema[:description] ||= @schema.delete(:desc) if @schema.key?(:desc)
26+
@schema[:type] ||= :string
27+
end
28+
29+
# @return [String]
30+
def type
31+
@schema[:type]
1432
end
33+
34+
# @return [String, NilClass]
35+
def description
36+
@schema[:description]
37+
end
38+
39+
alias required? required
1540
end
1641

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

42-
def param(name, **options)
43-
parameters[name] = Parameter.new(name, **options)
67+
# Define a parameter for the tool.
68+
# Examples:
69+
# ```ruby
70+
# param :latitude, desc: "Latitude (e.g., 52.5200)" # Shorthand format
71+
#
72+
# param :longitude, type: 'number', description: "Longitude (e.g., 13.4050)", required: false # Longer format
73+
#
74+
# param :unit, type: :string, enum: %w[f c], description: "Temperature unit (e.g., celsius, fahrenheit)"
75+
#
76+
# param :location, type: :object, desc: "Country and city where weather is requested.", properties: {
77+
# country: { type: :string, description: "Full name of the country." },
78+
# city: { type: :string, description: "Full name of the city." }
79+
# }
80+
# ```
81+
# @param name [Symbol]
82+
# @param schema [Hash{String|Symbol => String|Numeric|Boolean|Hash|Array|NilClass}, NilClass]
83+
def param(name, required: true, **schema)
84+
parameters[name] = Parameter.new(name, required: required, **schema)
4485
end
4586

4687
def parameters

lib/ruby_llm/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module RubyLLM
4-
VERSION = '1.2.0'
4+
VERSION = '1.3.0'
55
end

0 commit comments

Comments
 (0)