Toolsets

DSPy::Tools::Toolset groups related Ruby methods and exports selected methods as individual tools. Use this page for the supported DSL, export behavior, built-in names, and runtime boundaries. For an application-focused extension recipe, see Custom Toolsets.

When to Use Toolsets

Use a toolset when several operations belong to one capability and should share naming or implementation logic. A standalone DSPy::Tools::Base subclass is simpler for one operation.

Export only operations that an agent may call. A tool declaration is an interface, not permission to perform the operation.

Define and Export a Toolset

Direct-require the Toolset feature when a file does not otherwise load dspy:

require "dspy/tools/toolset"

class TextStatsToolset < DSPy::Tools::Toolset
  extend T::Sig

  toolset_name "stats"

  tool :word_count, description: "Count whitespace-separated words"
  tool :line_count, description: "Count lines"

  sig { params(text: String).returns(Integer) }
  def word_count(text:)
    text.split(/\s+/).reject(&:empty?).length
  end

  sig { params(text: String).returns(Integer) }
  def line_count(text:)
    text.lines.length
  end
end

toolset = TextStatsToolset.new
puts toolset.word_count(text: "one two")

tools = TextStatsToolset.to_tools
puts tools.map(&:name)

to_tools is a class method. It calls new with no arguments once, then gives every exported ToolProxy that same instance. Current Toolsets intended for to_tools therefore need a zero-argument constructor. Do not create a configured instance and expect instance.class.to_tools to preserve that instance or its constructor arguments.

Pass the returned proxies to a DSPy::ReAct agent with an explicit loop bound:

agent = DSPy::ReAct.new(
  AnalyzeText,
  tools: TextStatsToolset.to_tools,
  max_iterations: 5
)

Text Processing Toolset Example

The core gem ships DSPy::Tools::TextProcessingToolset, but require "dspy" does not load that optional feature constant. This complete example uses only its portable Ruby word-count operation; it needs no API key, network access, or external search binary.

require "dspy/tools/text_processing_toolset"

toolset = DSPy::Tools::TextProcessingToolset.new
tools = toolset.class.to_tools

puts tools.map(&:name).join(",")

word_count = tools.to_h { |tool| [tool.name, tool] }.fetch("text_wc")
puts word_count.call(text: "one two\nthree")

The output begins with the eight exported names listed under Text Processing Operations, followed by:

Lines: 2, Words: 3, Characters: 13

How Toolset Export Works

  1. The tool DSL records an exposed Ruby method, generated name, and description.
  2. .to_tools constructs one zero-argument Toolset instance.
  3. Each ToolProxy delegates to one exposed method on that shared instance.
  4. Sorbet method signatures provide the parameter schema and runtime coercion used by dynamic_call.
  5. DSPy::ReAct presents the proxies to the model and controls the model-directed loop.

DSL Methods

toolset_name(name)

Sets the prefix for names generated by tool:

class DatabaseToolset < DSPy::Tools::Toolset
  toolset_name "db"
  tool :query # exported as "db_query"

  def query
    "ok"
  end
end

Without an explicit name, DSPy.rb removes the Toolset suffix from the class name and lowercases the remainder.

tool(method_name, options)

Exposes one instance method. tool_name: overrides the generated <toolset_name>_<method_name> name.

tool :search,
  tool_name: "catalog_lookup",
  description: "Look up an item in the product catalog"

Descriptions should state the operation and its relevant scope. They do not enforce that scope.

Type Safety

Sorbet signatures drive JSON Schema generation and conversion for proxy calls. Whether a keyword is required comes from the Ruby method signature: T.nilable permits nil, while a Ruby default permits omission.

class Priority < T::Enum
  enums do
    Low = new("low")
    High = new("high")
  end
end

class TaskToolset < DSPy::Tools::Toolset
  extend T::Sig

  toolset_name "task"
  tool :assign, description: "Assign a task priority"

  sig do
    params(
      task_id: String,
      priority: Priority,
      note: T.nilable(String)
    ).returns(String)
  end
  def assign(task_id:, priority:, note: nil)
    "#{task_id}: #{priority.serialize}#{": #{note}" if note}"
  end
end

Basic Types

Toolset schemas support String, Integer, Float, Numeric, and T::Boolean.

Enums

T::Enum values are represented as their serialized strings and converted back to enum instances at the tool boundary.

Structs

T::Struct parameters become object schemas and are converted to struct instances before the method runs.

Collections

Typed arrays and hashes preserve their declared element or value types during schema generation and conversion.

Nullable and Optional Parameters

T.nilable(String) allows a string or nil. A method default such as note: nil makes the keyword omittable. These are separate properties.

Union Types

T.any(...) becomes a schema union. Prefer unions whose variants can be distinguished reliably from their JSON representation.

Supported Sorbet Types Reference

Sorbet type Schema shape Runtime conversion
String string yes
Integer integer yes
Float, Numeric number yes
T::Boolean boolean yes
T::Enum string enum yes
T::Struct object yes
T::Array[Type] array element-wise
T::Hash[K, V] object key/value conversion
T.nilable(Type) type or null yes
T.any(T1, T2) union yes, when distinguishable

Schema Generation Examples

Inspect a proxy’s schema rather than maintaining a second hand-written schema:

assign = TaskToolset.to_tools.to_h { |tool| [tool.name, tool] }.fetch("task_assign")
puts assign.schema

The schema describes the callable name, description, and parameters. It does not describe authorization, sandboxing, input trust, timeouts, or transaction behavior.

Text Processing Operations

DSPy::Tools::TextProcessingToolset exports exactly these names:

Exported tool Ruby method Notes
text_grep grep invokes the system grep command through a temporary file
text_wc word_count pure Ruby line, word, and character counts
text_rg ripgrep invokes the external rg command through a temporary file
text_extract_lines extract_lines selects a line or line range
text_filter_lines filter_lines applies a Ruby regular expression
text_unique_lines unique_lines removes duplicate lines
text_sort_lines sort_lines sorts lexically or numerically
text_summarize_text summarize_text returns deterministic text statistics

The grep and ripgrep helpers interpolate pattern values into shell command strings and use temporary files. Quotes and option-like patterns can change command interpretation. The implementation suppresses command stderr, has no elapsed-time or output cap, and unlinks its temporary file only after the normal command path. These are convenience utilities for trusted inputs in a controlled runtime, not a shell-isolation boundary. Verify binary availability, constrain pattern and input size, apply a process timeout and cleanup strategy where needed, and isolate untrusted workloads outside the application process.

text_filter_lines compiles its pattern as a Ruby regular expression. Constrain pattern complexity and input size when patterns are not application-owned; type conversion does not prevent expensive regular-expression evaluation.

The core gem also ships DSPy::Tools::GitHubCLIToolset at dspy/tools/github_cli_toolset. Its operations depend on the gh executable and the caller’s GitHub authentication and repository permissions.

LLM Usage

An agent selects the exported name, not the Ruby method name. This example uses the pure-Ruby text_wc operation; it does not expose the shell-backed text_grep or text_rg proxies to model-supplied arguments:

{
  "action": "text_wc",
  "action_input": {
    "text": "one two\nthree"
  }
}

Treat model-produced text as untrusted input and cap its byte size before processing it. A pure-Ruby operation avoids this Toolset’s shell boundary, but its schema still does not impose resource limits or an elapsed-time deadline. Pass only the reviewed proxies an agent needs, and keep max_iterations and operation-level limits explicit.

Testing

Test both the Ruby operation and the exported proxy contract:

RSpec.describe TextStatsToolset do
  it "counts words and exports stable names" do
    expect(described_class.new.word_count(text: "one two")).to eq(2)

    tools = described_class.to_tools
    expect(tools.map(&:name)).to eq(%w[stats_word_count stats_line_count])
    expect(tools.first.dynamic_call("text" => "one two")).to eq(2)
  end
end

For packaged built-ins, test in a clean subprocess against files listed by the gemspec. A normal spec process may already have loaded dspy, masking a missing direct-load dependency.

Limitations

  • .to_tools calls new without arguments; constructor-required Toolsets cannot use this export path.
  • Tool parameters should be keyword arguments so schema requiredness and coercion are represented correctly.
  • Each exposed method becomes a separate proxy; method chaining is not exposed.
  • A direct proxy call delegates failures from the method. dynamic_call converts parsing, coercion, and method exceptions to an "Error: ..." string. Define and test the failure contract your application consumes.
  • Tool schemas do not cap input size, execution time, retries, or agent iterations.

Runtime Boundaries

A Toolset schema describes call shape and performs supported type conversion. It does not grant authorization, sanitize values for a shell or query, provide a sandbox, impose a timeout, isolate side effects, or make an operation idempotent. The application must enforce permissions and input policy, bound resource use and the agent loop, handle partial failures, and define retry and transaction behavior. max_iterations limits model-directed steps; it does not cancel a slow subprocess, network request, or tool method.

Keep those controls in deterministic Ruby around or inside the tool. Use OS or container isolation when an operation can execute untrusted code or commands; a Sorbet schema is not that isolation.

Design Decisions

The tool DSL exposes only declared methods. This keeps the model-facing interface explicit and gives each operation a stable name and description. Sorbet signatures keep the schema next to the Ruby implementation, while application-owned policy remains separate from schema generation.

Apply Toolsets in an Agent

Apply the operational recipe in Custom Toolsets, then pass only the minimum reviewed proxies to a module or bounded ReAct agent.