Skip to content

Commit 456ecbb

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 456ecbb

19 files changed

+1966
-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/tool.rb

+67-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,59 @@
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 [Hash{String => String|Array|Hash|Boolean|NilClass}]
613
class Parameter
7-
attr_reader :name, :type, :description, :required
14+
attr_reader :name, :required
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+
@schema = deep_stringify_keys(schema.to_h.dup)
24+
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']
32+
end
33+
34+
# @return [String, NilClass]
35+
def description
36+
@schema['description']
37+
end
38+
39+
##
40+
# @return [Hash{String|Symbol => String|Array|Hash|Boolean|NilClass}, NilClass]
41+
def schema
42+
deep_stringify_keys(@schema.dup)
43+
end
44+
45+
alias required? required
46+
47+
private
48+
49+
##
50+
# @param obj [Hash, Array, String, Numeric, Boolean]
51+
def deep_stringify_keys(obj)
52+
if obj.is_a?(Hash)
53+
obj.transform_keys(&:to_s).transform_values { |v| deep_stringify_keys(v) }
54+
elsif obj.is_a?(Array)
55+
obj.map { |v| deep_stringify_keys(v) }
56+
else
57+
obj
58+
end
1459
end
1560
end
1661

@@ -39,8 +84,24 @@ def description(text = nil)
3984
@description = text
4085
end
4186

42-
def param(name, **options)
43-
parameters[name] = Parameter.new(name, **options)
87+
# Define a parameter for the tool.
88+
# Examples:
89+
# ```ruby
90+
# param :latitude, desc: "Latitude (e.g., 52.5200)" # Shorthand format
91+
#
92+
# param :longitude, type: 'number', description: "Longitude (e.g., 13.4050)", required: false # Longer format
93+
#
94+
# param :unit, type: :string, enum: %w[f c], description: "Temperature unit (e.g., celsius, fahrenheit)"
95+
#
96+
# param :location, type: :object, desc: "Country and city where weather is requested.", properties: {
97+
# country: { type: :string, description: "Full name of the country." },
98+
# city: { type: :string, description: "Full name of the city." }
99+
# }
100+
# ```
101+
# @param name [Symbol]
102+
# @param schema [Hash{String|Symbol => String|Numeric|Boolean|Hash|Array|NilClass}, NilClass]
103+
def param(name, required: true, **schema)
104+
parameters[name] = Parameter.new(name, required: required, **schema)
44105
end
45106

46107
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)