Tools

Tools are callable functions that agents can invoke to interact with external systems, fetch data, or perform actions.

Defining a Tool

Create a tool by subclassing Riffer::Tool:

class WeatherTool < Riffer::Tool
  description "Gets the current weather for a city"

  params do
    required :city, String, description: "The city name"
    optional :units, String, default: "celsius", enum: ["celsius", "fahrenheit"]
  end

  def call(context:, city:, units: nil)
    weather = WeatherAPI.fetch(city, units: units || "celsius")
    text("The weather in #{city} is #{weather.temperature} #{units}.")
  end
end

Configuration Methods

description

Sets a description that helps the LLM understand when to use the tool:

class SearchTool < Riffer::Tool
  description "Searches the knowledge base for relevant information"
end

identifier / name

Sets a custom identifier (defaults to snake_case class name):

class SearchTool < Riffer::Tool
  identifier 'kb_search'
end

SearchTool.identifier  # => "kb_search"
SearchTool.name        # => "kb_search" (alias)

params

Defines the tool’s parameters using a DSL:

class CreateOrderTool < Riffer::Tool
  params do
    required :product_id, Integer, description: "The product ID"
    required :quantity, Integer, description: "Number of items"
    optional :notes, String, description: "Order notes"
    optional :priority, String, default: "normal", enum: ["low", "normal", "high"]
  end
end

Parameter DSL

required

Defines a required parameter:

params do
  required :name, String, description: "The user's name"
  required :age, Integer, description: "The user's age"
end

Options:

optional

Defines an optional parameter:

params do
  optional :limit, Integer, default: 10, description: "Max results"
  optional :format, String, enum: ["json", "xml"], description: "Output format"
end

Options:

Supported Types

Ruby Type JSON Schema Type
String string
Integer integer
Float number
Riffer::Boolean boolean
TrueClass / FalseClass boolean
Array array
Hash object

Riffer::Boolean is the preferred way to declare boolean parameters. TrueClass and FalseClass continue to work for backwards compatibility.

Nested Parameters

Tool params support the same nested DSL as structured output β€” nested objects (Hash with block), typed arrays (Array, of:), and arrays of objects (Array with block). See the structured output section in Agents for full syntax.

class CreateOrderTool < Riffer::Tool
  description "Creates an order"

  params do
    required :items, Array, description: "Line items" do
      required :product_id, Integer
      required :quantity, Integer
      optional :notes, String
    end
    required :shipping, Hash, description: "Shipping address" do
      required :street, String
      required :city, String
      optional :zip, String
    end
  end

  def call(context:, items:, shipping:)
    # items is an Array of Hashes with symbolized keys
    # shipping is a Hash with symbolized keys
  end
end

The call Method

Every tool must implement the call method and return a Riffer::Tools::Response:

def call(context:, **kwargs)
  # context - The context passed to agent.generate()
  # kwargs  - Validated parameters
  #
  # Must return a Riffer::Tools::Response
end

Accessing Context

The context argument receives whatever was passed as context: to generate:

class UserOrdersTool < Riffer::Tool
  description "Gets the current user's orders"

  def call(context:)
    user_id = context&.dig(:user_id)
    unless user_id
      return error("No user ID provided")
    end

    orders = Order.where(user_id: user_id)
    text(orders.map(&:to_s).join("\n"))
  end
end

# Usage
agent.generate("Show my orders", context: {user_id: 123})

Response Objects

All tools must return a Riffer::Tools::Response object from their call method. Riffer::Tool provides shorthand methods for creating responses.

Success Responses

Use text for string responses and json for structured data:

def call(context:, query:)
  results = Database.search(query)

  if results.empty?
    text("No results found for '#{query}'")
  else
    text(results.map { |r| "- #{r.title}: #{r.summary}" }.join("\n"))
  end
end

text

Converts the result to a string via to_s:

text("Hello, world!")
# => content: "Hello, world!"

text(42)
# => content: "42"

json

Converts the result to JSON via to_json:

json({name: "Alice", age: 30})
# => content: '{"name":"Alice","age":30}'

json([1, 2, 3])
# => content: '[1,2,3]'

Error Responses

Use error(message, type:) for errors:

def call(context:, user_id:)
  user = User.find_by(id: user_id)

  unless user
    return error("User not found", type: :not_found)
  end

  text("User: #{user.name}")
end

The error type is any symbol that describes the error category:

error("Invalid input", type: :validation_error)
error("Service unavailable", type: :service_error)
error("Rate limit exceeded", type: :rate_limit)

If no type is specified, it defaults to :execution_error.

Using Riffer::Tools::Response Directly

The shorthand methods delegate to Riffer::Tools::Response. You can also use the class directly if preferred:

Riffer::Tools::Response.text("Hello")
Riffer::Tools::Response.json({data: [1, 2, 3]})
Riffer::Tools::Response.error("Failed", type: :custom_error)

Response Methods

response = text("result")
response.content        # => "result"
response.success?       # => true
response.error?         # => false
response.error_message  # => nil
response.error_type     # => nil

error_response = error("failed", type: :not_found)
error_response.content        # => "failed"
error_response.success?       # => false
error_response.error?         # => true
error_response.error_message  # => "failed"
error_response.error_type     # => :not_found

Timeout Configuration

Configure timeouts to prevent tools from running indefinitely. The default timeout is 10 seconds.

class SlowExternalApiTool < Riffer::Tool
  description "Calls a slow external API"
  timeout 30  # 30 seconds

  def call(context:, query:)
    result = ExternalAPI.search(query)
    text(result)
  end
end

When a tool times out, the error is reported to the LLM with error type :timeout_error, allowing it to respond appropriately (e.g., suggest retrying or using a different approach).

Validation

Arguments are automatically validated before call is invoked:

Validation errors are captured and sent back to the LLM as tool results with error type :validation_error.

JSON Schema Generation

Riffer automatically generates JSON Schema for each tool:

WeatherTool.parameters_schema
# => {
#   type: "object",
#   properties: {
#     "city" => {type: "string", description: "The city name"},
#     "units" => {type: "string", enum: ["celsius", "fahrenheit"]}
#   },
#   required: ["city"],
#   additionalProperties: false
# }

Registering Tools with Agents

Static Registration

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'
  uses_tools [WeatherTool, SearchTool]
end

Dynamic Registration

Use a lambda for context-aware tool resolution:

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'

  uses_tools ->(context) {
    tools = [PublicSearchTool]

    if context&.dig(:user)&.premium?
      tools << PremiumAnalyticsTool
    end

    if context&.dig(:user)&.admin?
      tools << AdminTool
    end

    tools
  }
end

Error Handling

Errors can be returned explicitly using error:

def call(context:, query:)
  results = ExternalAPI.search(query)
  json(results)
rescue RateLimitError => e
  error("API rate limit exceeded, please try again later", type: :rate_limit)
rescue => e
  error("Search failed: #{e.message}")
end

Unhandled RuntimeError exceptions are caught by Riffer and converted to error responses with type :execution_error. For expected execution errors, raise Riffer::ToolExecutionError β€” these are also caught and returned to the LLM. Programming bugs (NoMethodError, NameError, TypeError, etc.) propagate to the caller. It’s recommended to handle expected errors explicitly for better error messages.

The LLM receives the error message and can decide how to respond (retry, apologize, ask for different input, etc.).

Tool Runtime (Experimental)

Warning: This feature is experimental and may be removed or changed without warning in a future release.

By default, tool calls are executed sequentially in the current thread using Riffer::ToolRuntime::Inline. You can change how tool calls are executed by configuring a different tool runtime.

Built-in Runtimes

Runtime Description
Riffer::ToolRuntime::Inline Executes tool calls sequentially (default)
Riffer::ToolRuntime::Threaded Executes tool calls concurrently using threads

Per-Agent Configuration

Use the tool_runtime class method on your agent:

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'
  uses_tools [WeatherTool, SearchTool]
  tool_runtime Riffer::ToolRuntime::Threaded
end

Accepted values:

Dynamic Resolution

Use a lambda for context-aware runtime selection:

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'
  uses_tools [WeatherTool, SearchTool]

  tool_runtime ->(context) {
    context&.dig(:parallel) ? Riffer::ToolRuntime::Threaded.new : Riffer::ToolRuntime::Inline.new
  }
end

agent.generate("Do work", context: {parallel: true})

When the lambda accepts a parameter, it receives the context. Zero-arity lambdas are also supported.

Global Configuration

Set a default tool runtime for all agents:

Riffer.configure do |config|
  config.tool_runtime = Riffer::ToolRuntime::Threaded
end

Per-agent configuration overrides the global default.

Threaded Runtime Considerations

When using Riffer::ToolRuntime::Threaded, each tool call runs in its own thread. The around_tool_call hook also runs inside that thread. Be mindful of thread-local state β€” for example, ActiveRecord::Base.connection, RequestStore, or any Thread.current[] values may not be available or may behave differently across threads. Ensure your tools and hooks are thread-safe.

Threaded Runtime Options

The threaded runtime accepts a max_concurrency option (default: 5):

class MyAgent < Riffer::Agent
  model 'openai/gpt-4o'
  uses_tools [WeatherTool, SearchTool]
  tool_runtime Riffer::ToolRuntime::Threaded.new(max_concurrency: 3)
end

Custom Runtimes

Create a custom runtime by subclassing Riffer::ToolRuntime and overriding the private dispatch_tool_call method:

class HttpToolRuntime < Riffer::ToolRuntime
  private

  def dispatch_tool_call(tool_call, tools:, context:)
    # Dispatch tool execution to an external service
    response = HttpClient.post("/tools/execute", {
      name: tool_call.name,
      arguments: tool_call.arguments
    })
    Riffer::Tools::Response.text(response.body)
  rescue Riffer::ToolExecutionError => e
    Riffer::Tools::Response.error(e.message, type: :execution_error)
  rescue RuntimeError => e
    Riffer::Tools::Response.error("Error executing tool: #{e.message}", type: :execution_error)
  end
end

Around-Call Hook

Each tool call is wrapped by the around_tool_call method, which yields by default. Override it in a subclass to add instrumentation, logging, or other cross-cutting concerns:

class InstrumentedRuntime < Riffer::ToolRuntime::Inline
  private

  def around_tool_call(tool_call, context:)
    start = Time.now
    result = yield
    duration = Time.now - start
    Rails.logger.info("Tool #{tool_call.name} took #{duration}s")
    result
  end
end

Subclasses inherit the hook and can override it further.