Custom Toolsets

Use this application recipe after learning the toolset_name, tool, and .to_tools APIs in the Toolsets reference.

The current .to_tools export path constructs the Toolset with new and no arguments. The recipe is therefore stateless and has a zero-argument constructor. If a capability needs constructor-injected clients or per-request state, wrap it in individual DSPy::Tools::Base instances or keep the state outside the current Toolset export path.

Build a Bounded Text Lookup Toolset

This operation accepts text already provided by the application. It does not read files, start a process, or make a network request. It caps input and output, treats the query as literal text, and returns a stable JSON failure envelope.

require "dspy/tools/toolset"
require "json"

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

  MAX_TEXT_BYTES = 100_000
  MAX_QUERY_BYTES = 200
  MAX_MATCHES = 20

  toolset_name "release_notes"
  tool :find_lines, description: "Find up to 20 lines containing literal text"

  sig { params(text: String, query: String, limit: Integer).returns(String) }
  def find_lines(text:, query:, limit: 10)
    return failure("text_too_large") if text.bytesize > MAX_TEXT_BYTES
    return failure("invalid_query") if query.empty? || query.bytesize > MAX_QUERY_BYTES
    return failure("invalid_limit") unless (1..MAX_MATCHES).cover?(limit)
    return failure("invalid_encoding") unless text.valid_encoding? && query.valid_encoding?

    needle = query.downcase
    matches = text.each_line.with_index(1).filter_map do |line, number|
      { line: number, text: line.chomp } if line.downcase.include?(needle)
    end.first(limit + 1)
    truncated = matches.length > limit

    JSON.generate(ok: true, matches: matches.first(limit), truncated: truncated)
  rescue Encoding::CompatibilityError, ArgumentError => error
    failure("incompatible_encoding", detail: error.message)
  end

  private

  sig { params(code: String, detail: T.nilable(String)).returns(String) }
  def failure(code, detail: nil)
    JSON.generate(ok: false, error: code, detail: detail)
  end
end

tools = ReleaseNotesToolset.to_tools
lookup = tools.to_h { |tool| [tool.name, tool] }.fetch("release_notes_find_lines")
puts lookup.call(text: "Added caching\nFixed retries\n", query: "fixed", limit: 5)

The limits here bound this one in-memory operation. They do not authorize access to the source text or impose a timeout on an agent loop.

Attach It to a Bounded Agent

The application chooses which tools enter the agent and how many model-directed steps are allowed:

agent = DSPy::ReAct.new(
  SummarizeRelease,
  tools: ReleaseNotesToolset.to_tools,
  max_iterations: 5
)

Keep fixed sequencing in Ruby. Use ReAct only when model-directed choice among the exposed operations is useful.

max_iterations limits model-directed steps. It is not an elapsed-time deadline and does not cancel a running tool, subprocess, or network request; apply those limits at the operation boundary.

Define the Operational Contract

Before exposing an application operation, decide and test:

  • Authority: which user, tenant, or service may perform it; the schema has no authorization semantics.
  • Input policy: allowed identifiers, paths, patterns, sizes, and encodings; type conversion is not sanitization.
  • Side effects: read-only versus mutating behavior, idempotency, transaction boundaries, and partial-result cleanup.
  • Time and resources: operation timeout, response cap, concurrency, retries, and the agent’s max_iterations.
  • Failures: stable error values or exceptions, which failures are retryable, and what may be shown to the model.

For shell commands, database queries, filesystem access, or HTTP calls, enforce these controls at the owning boundary. A Toolset proxy neither sandboxes the operation nor supplies a timeout. Prefer argument-vector process APIs over shell interpolation, use allowlists for reachable resources, and isolate untrusted execution at the OS or container level.

Test the Exported Boundary

Exercise the method directly, then exercise the proxy with the JSON-shaped values an agent supplies:

RSpec.describe ReleaseNotesToolset do
  let(:proxy) do
    described_class.to_tools
      .to_h { |tool| [tool.name, tool] }
      .fetch("release_notes_find_lines")
  end

  it "returns bounded matches through dynamic_call" do
    result = JSON.parse(
      proxy.dynamic_call(
        "text" => "Added caching\nFixed retries\n",
        "query" => "fixed",
        "limit" => 5
      )
    )

    expect(result).to include("ok" => true, "truncated" => false)
    expect(result.fetch("matches").length).to eq(1)
  end

  it "rejects oversized input before processing it" do
    result = JSON.parse(
      proxy.call(text: "x" * 100_001, query: "x", limit: 5)
    )

    expect(result).to include("ok" => false, "error" => "text_too_large")
  end
end

Also test authorization denials, timeout behavior, cleanup after failures, and retry semantics for any real external capability. Those tests belong to the application operation, not only to schema generation.