Tools

Introduction

Overview

A tool is how a model reaches outside its own head. Without tools, the model can only produce text. With a tool, it can run a shell command, query a database, or fetch a web page. The model decides when a tool fits the request; the runtime calls it and sends the result back.

A tool is a Ruby class with a name, a description, and a call method. The name tells the model what the tool is called. The description tells the model when to use it. The call method receives the arguments and returns the result. That is the entire contract: name, description, parameters, and a method that runs.

How it works

A tool is a subclass of LLM::Tool with a name, description, and optional typed parameters. The model sees the name and description and decides whether to call it. When it does, the runtime serialises the arguments and passes them to call.

If call raises, the runtime rescues it and returns a structured error to the model instead. The conversation stays valid. You can also handle errors yourself inside call by rescuing and returning a domain-specific error hash.

class Shell < LLM::Tool
  name "shell"
  description "execute a shell command"
  parameter :name, String, "the command's name"
  parameter :arguments, Array[String], "One or more arguments"
  required %i[name]
  defaults arguments: []

  def call(name:, arguments: [])
    out = `#{name.shellescape} #{arguments.map(&:shellescape).join(" ")}`
    {ok: $?.success?, out:}
  end
end

llm = LLM.deepseek(key: ENV["KEY"])
agent = LLM::Agent.new(llm, tools: [Shell], stream: $stdout)
agent.talk "What files are in the current working directory?"

Why would I use it?

Tools are how the model interacts with the outside world. Without them, the model can only produce text. With tools, it can query databases, run shell commands, fetch web pages, or call APIs. The tool loop stays alive even when a tool fails. One broken tool does not derail the conversation.

Notes

Confirmation gates tools behind explicit approval. List tool names in LLM::Agent.confirm to block execution until you override on_tool_confirmation. Confirmation also accepts a Symbol for lazy resolution. A Symbol lets the confirmed set change per-instance based on runtime conditions:

class AdminAgent < LLM::Agent
  set confirm: %w[delete destroy shutdown]

  def on_tool_confirmation(fn, strategy)
    print "Run #{fn.name} with #{fn.arguments}? [y/N] "
    $stdin.gets&.match?(/\Ay\z/i) ? fn.task(strategy).wait : fn.cancel
  end
end

class AdaptiveAgent < LLM::Agent
  set confirm: :tools_that_need_confirmation

  def tools_that_need_confirmation
    some_condition ? %w[delete destroy] : %w[delete]
  end
end

Confirmation

Overview

Tools that perform destructive actions can be gated behind explicit approval. List their names in LLM::Agent.confirm to block execution until you override on_tool_confirmation. The default handler cancels the tool. Override it per-agent to prompt the user, log the decision, or auto-approve certain tools.

How it works

When you want to gate destructive tools behind explicit approval, list their names in LLM::Agent.confirm. Override on_tool_confirmation on the subclass to define your own approval flow. The default handler cancels the call.

class AdminAgent < LLM::Agent
  set confirm: %w[delete destroy shutdown]

  def on_tool_confirmation(fn, strategy)
    print "Run #{fn.name} with #{fn.arguments}? [y/N] "
    $stdin.gets&.match?(/\Ay\z/i) ? fn.task(strategy).wait : fn.cancel
  end
end

Why would I use it?

Confirmation prevents the model from running dangerous tools without user oversight. You decide the approval flow: a terminal prompt, a web socket, a background job queue.

Notes

Confirmation also accepts a Symbol for lazy resolution: set confirm: :tools_that_need_confirmation with a method that returns the list of tool names. This lets the confirmed set change per-instance based on runtime conditions.

Errors

Overview

A tool that raises does not crash the conversation. The runtime catches the exception, wraps it into a structured error, and returns it to the model. The model can read the error, decide what went wrong, and try something else. The tool loop stays alive no matter what.

How it works

If call raises, the runtime returns {error: true, type: "RuntimeError", message: "boom"} to the model. You can also rescue inside call and return your own error shape that gives the model more context.

class Shell < LLM::Tool
  name "shell"
  description "execute a shell command"

  def call(name:, arguments: [])
    out = `#{name} #{arguments.join(" ")}`
    {ok: $?.success?, out:}
  rescue Errno::ENOENT
    {ok: false, error: "command not found: #{name}"}
  end
end

Why would I use it?

Custom error handling gives the model domain-specific detail that helps it recover. Instead of a generic “RuntimeError: boom”, the model sees {ok: false, error: "command not found: ls"} and knows to correct the command name and try again.

Notes

The principle is the same either way: return something. A tool call must complete with a tool response. If you do not return a value and you do not raise, the runtime has nothing to send back and the conversation is stuck.