Skip to content

Commit 18870af

Browse files
committed
New RubyLLM::Schema/JSON schema powered params DSL for Tools
Enables full JSON schema support for parameters in Tools. You can use the excellent RubyLLM::Schema DSL, or a custom JSON schema. It's more elegant and more powerful than the old param helper, which is still supported. Fixes #480 Fixes #76
1 parent cf27430 commit 18870af

File tree

99 files changed

+6525
-4193
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+6525
-4193
lines changed

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ group :development do # rubocop:disable Metrics/BlockLength
2626
gem 'rubocop-performance'
2727
gem 'rubocop-rake', '>= 0.6'
2828
gem 'rubocop-rspec'
29-
gem 'ruby_llm-schema', '~> 0.1.0'
3029
gem 'simplecov', '>= 0.21'
3130
gem 'simplecov-cobertura'
3231

docs/_core_features/tools.md

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ After reading this guide, you will know:
2525

2626
* What Tools are and why they are useful.
2727
* How to define a Tool using `RubyLLM::Tool`.
28-
* How to define parameters for your Tools.
28+
* How to define parameters for your Tools (from quick helpers to full JSON Schema).
2929
* How to use Tools within a `RubyLLM::Chat`.
3030
* The execution flow when a model uses a Tool.
3131
* How to handle errors within Tools.
@@ -50,8 +50,11 @@ Define a tool by creating a class that inherits from `RubyLLM::Tool`.
5050
```ruby
5151
class Weather < RubyLLM::Tool
5252
description "Gets current weather for a location"
53-
param :latitude, desc: "Latitude (e.g., 52.5200)"
54-
param :longitude, desc: "Longitude (e.g., 13.4050)"
53+
54+
params do # the params DSL is only available in v1.9+. older versions should use the param helper instead
55+
string :latitude, description: "Latitude (e.g., 52.5200)"
56+
string :longitude, description: "Longitude (e.g., 13.4050)"
57+
end
5558

5659
def execute(latitude:, longitude:)
5760
url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"
@@ -68,12 +71,8 @@ end
6871

6972
1. **Inheritance:** Must inherit from `RubyLLM::Tool`.
7073
2. **`description`:** A class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise.
71-
3. **`param`:** A class method used to define each input parameter.
72-
* **Name:** The first argument (a symbol) is the parameter name. It will become a keyword argument in the `execute` method.
73-
* **`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.
74-
* **`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").
75-
* **`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.
76-
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.
74+
3. **`params`:** (v1.9+) The DSL for describing your input schema. Declare nested objects, arrays, enums, and optional fields in one place. If you only need flat keyword arguments, the older `param` (v1.0+) helper remains available. See [Using the `param` Helper for Simple Tools](#using-the-param-helper-for-simple-tools).
75+
4. **`execute` Method:** The instance method containing your Ruby code. It receives the keyword arguments defined by your schema and returns the payload the model will see (typically a String, Hash, or `RubyLLM::Content`).
7776

7877
> The tool's class name is automatically converted to a snake_case name used in the API call (e.g., `WeatherLookup` becomes `weather_lookup`). This is how the LLM would call it. You can override this by defining a `name` method in your tool class:
7978
>
@@ -86,29 +85,96 @@ end
8685
> ```
8786
{: .note }
8887
89-
### Provider-Specific Parameters
88+
## Declaring Parameters
89+
90+
RubyLLM ships with two complementary approaches:
91+
92+
* The **`params` DSL** for expressive, structured inputs. (v1.9+)
93+
* The **`param` helper** for quick, flat argument lists. (v1.0+)
94+
95+
Start with the DSL whenever you need anything beyond a handful of simple strings—it keeps complex schemas maintainable and identical across every provider.
96+
97+
### params DSL
9098
{: .d-inline-block }
9199
92100
v1.9.0+
93101
{: .label .label-green }
94102
95-
Some providers allow you to attach extra metadata to tool definitions (for example, Anthropic's `cache_control` directive for prompt caching). Use `with_params` on your tool class to declare these once and RubyLLM will merge them into the API payload when the provider understands them.
103+
When you need nested objects, arrays, enums, or union types, the `params do ... end` DSL produces the JSON Schema that function-calling models expect while staying Ruby-flavoured.
96104
97105
```ruby
98-
class TodoTool < RubyLLM::Tool
99-
description "Adds a task to the shared TODO list"
100-
param :title, desc: "Human-friendly task description"
106+
class Scheduler < RubyLLM::Tool
107+
description "Books a meeting"
101108
102-
with_params cache_control: { type: 'ephemeral' }
109+
params do
110+
object :window, description: "Time window to reserve" do
111+
string :start, description: "ISO8601 start time"
112+
string :finish, description: "ISO8601 end time"
113+
end
103114
104-
def execute(title:)
105-
Todo.create!(title:)
106-
"Added “#{title}” to the list."
115+
array :participants, of: :string, description: "Email addresses to invite"
116+
117+
any_of :format, description: "Optional meeting format" do
118+
string enum: %w[virtual in_person]
119+
null
120+
end
121+
end
122+
123+
def execute(window:, participants:, format: nil)
124+
# ...
107125
end
108126
end
109127
```
110128
111-
Provider-specific tool parameters are passed through verbatim. Use `RUBYLLM_DEBUG=true` and keep an eye on your logs when rolling out new metadata.
129+
RubyLLM bundles the DSL through [`ruby_llm-schema`](https://github.com/danielfriis/ruby_llm-schema), so every project has the same schema builders out of the box.
130+
131+
### Using the `param` Helper for Simple Tools
132+
133+
If your tool just needs a few scalar arguments, stick with the `param` helper. RubyLLM translates these declarations into JSON Schema under the hood.
134+
135+
```ruby
136+
class Distance < RubyLLM::Tool
137+
description "Calculates distance between two cities"
138+
param :origin, desc: "Origin city name"
139+
param :destination, desc: "Destination city name"
140+
param :units, type: :string, desc: "Unit system (metric or imperial)", required: false
141+
142+
def execute(origin:, destination:, units: "metric")
143+
# ...
144+
end
145+
end
146+
```
147+
148+
### Supplying JSON Schema Manually
149+
{: .d-inline-block }
150+
151+
v1.9.0+
152+
{: .label .label-green }
153+
154+
Prefer to own the JSON Schema yourself? Pass a schema hash (or a class/object responding to `#to_json_schema`) directly to `params`:
155+
156+
```ruby
157+
class Lookup < RubyLLM::Tool
158+
description "Performs catalog lookups"
159+
160+
params schema: {
161+
type: "object",
162+
properties: {
163+
sku: { type: "string", description: "Product SKU" },
164+
locale: { type: "string", description: "Country code", default: "US" }
165+
},
166+
required: %w[sku],
167+
additionalProperties: false,
168+
strict: true
169+
}
170+
171+
def execute(sku:, locale: "US")
172+
# ...
173+
end
174+
end
175+
```
176+
177+
RubyLLM normalizes symbol keys, deep duplicates the schema, and sends it to providers unchanged. This gives you full control when you need it.
112178

113179
## Returning Rich Content from Tools
114180

@@ -286,6 +352,35 @@ chat.ask("Check weather for every major city...")
286352
> Raising an exception in `on_tool_call` breaks the conversation flow - the LLM expects a tool response after requesting a tool call. This can leave the chat in an inconsistent state. Consider using better models or clearer tool descriptions to prevent loops instead of hard limits.
287353
{: .warning }
288354

355+
## Advanced Tool Metadata
356+
357+
### Provider-Specific Parameters
358+
{: .d-inline-block }
359+
360+
v1.9.0+
361+
{: .label .label-green }
362+
363+
Some providers accept additional metadata alongside the JSON Schema—for example, Anthropic’s `cache_control` hints. Use `with_params` to declare these once on the tool class and RubyLLM will merge them into the payload when the provider supports the keys.
364+
365+
```ruby
366+
class TodoTool < RubyLLM::Tool
367+
description "Adds a task to the shared TODO list"
368+
369+
params do
370+
string :title, description: "Human-friendly task description"
371+
end
372+
373+
with_params cache_control: { type: "ephemeral" }
374+
375+
def execute(title:)
376+
Todo.create!(title:)
377+
"Added “#{title}” to the list."
378+
end
379+
end
380+
```
381+
382+
Provider metadata is passed through verbatim—turn on `RUBYLLM_DEBUG=true` if you want to inspect the final payload while experimenting.
383+
289384
## Advanced: Halting Tool Continuation
290385

291386
After a tool executes, the LLM normally continues the conversation to explain what happened. In rare cases, you might want to skip this and return the tool result directly.

gemfiles/rails_7.1.gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ group :development do
2424
gem "rubocop-performance"
2525
gem "rubocop-rake", ">= 0.6"
2626
gem "rubocop-rspec"
27-
gem "ruby_llm-schema", "~> 0.1.0"
2827
gem "simplecov", ">= 0.21"
2928
gem "simplecov-cobertura"
3029
gem "activerecord-jdbcsqlite3-adapter", platform: "jruby"

gemfiles/rails_7.2.gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ group :development do
2424
gem "rubocop-performance"
2525
gem "rubocop-rake", ">= 0.6"
2626
gem "rubocop-rspec"
27-
gem "ruby_llm-schema", "~> 0.1.0"
2827
gem "simplecov", ">= 0.21"
2928
gem "simplecov-cobertura"
3029
gem "activerecord-jdbcsqlite3-adapter", platform: "jruby"

gemfiles/rails_8.0.gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ group :development do
2424
gem "rubocop-performance"
2525
gem "rubocop-rake", ">= 0.6"
2626
gem "rubocop-rspec"
27-
gem "ruby_llm-schema", "~> 0.1.0"
2827
gem "simplecov", ">= 0.21"
2928
gem "simplecov-cobertura"
3029
gem "activerecord-jdbcsqlite3-adapter", platform: "jruby"

gemfiles/rails_8.1.gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ group :development do
2424
gem "rubocop-performance"
2525
gem "rubocop-rake", ">= 0.6"
2626
gem "rubocop-rspec"
27-
gem "ruby_llm-schema", "~> 0.1.0"
2827
gem "simplecov", ">= 0.21"
2928
gem "simplecov-cobertura"
3029
gem "activerecord-jdbcsqlite3-adapter", platform: "jruby"

lib/ruby_llm/chat.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
193193
@on[:tool_call]&.call(tool_call)
194194
result = execute_tool tool_call
195195
@on[:tool_result]&.call(result)
196-
content = content_like?(result) ? result : result.to_s
196+
tool_payload = result.is_a?(Tool::Halt) ? result.content : result
197+
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
197198
message = add_message role: :tool, content:, tool_call_id: tool_call.id
198199
@on[:end_message]&.call(message)
199200

lib/ruby_llm/providers/anthropic/tools.rb

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,13 @@ def format_tool_result_block(msg)
5353
end
5454

5555
def function_for(tool)
56+
input_schema = tool.params_schema ||
57+
RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema
58+
5659
declaration = {
5760
name: tool.name,
5861
description: tool.description,
59-
input_schema: {
60-
type: 'object',
61-
properties: clean_parameters(tool.parameters),
62-
required: required_parameters(tool.parameters)
63-
}
62+
input_schema: input_schema || default_input_schema
6463
}
6564

6665
return declaration if tool.provider_params.empty?
@@ -95,17 +94,14 @@ def parse_tool_calls(content_blocks)
9594
tool_calls.empty? ? nil : tool_calls
9695
end
9796

98-
def clean_parameters(parameters)
99-
parameters.transform_values do |param|
100-
{
101-
type: param.type,
102-
description: param.description
103-
}.compact
104-
end
105-
end
106-
107-
def required_parameters(parameters)
108-
parameters.select { |_, param| param.required }.keys
97+
def default_input_schema
98+
{
99+
'type' => 'object',
100+
'properties' => {},
101+
'required' => [],
102+
'additionalProperties' => false,
103+
'strict' => true
104+
}
109105
end
110106
end
111107
end

0 commit comments

Comments
 (0)