Tools

A tool is anything the model can invoke by name with structured arguments: read a file, run a shell command, delegate to a sub-agent. Brute accepts tools in four shapes, normalizes them through one adapter, and executes them concurrently.

The four ways to define a tool

1. Brute::Tool subclass — how the built-ins are written

Brute::Tool is a tiny, dependency-free base class with a familiar description/param DSL:

class FSRead < Brute::Tool
  description "Read the contents of a file"
  param :file_path, type: "string", desc: "Path to the file", required: true

  def name; "read"; end
  def execute(file_path:, **) = File.read(file_path)
end

For arguments that don’t fit a flat param list, pass a raw JSON schema:

class Question < Brute::Tool
  description "Ask the user a question"
  params({ type: "object", properties: { text: { type: "string" } }, required: %w[text] })
  def execute(text:) = ...
end

2. Inline Hash — the quickest tool

{
  name:        "echo",
  description: "Echo the input back",
  params:      { msg: { type: "string", required: true } },
  execute:     ->(msg:) { msg },
}

3. Brute::Turn::ToolPipeline — when the tool needs middleware

A ToolPipeline runs the tool call through its own middleware stack — param validation, file-mutation queueing, snapshotting, logging:

read = Brute::Turn::ToolPipeline.new(
  name:        "read",
  description: "Read a file's contents",
  params:      { file_path: { type: "string", required: true } },
) do
  use Brute::Middleware::Tool::ValidateParams
  run ->(env) { env[:result] = File.read(env[:arguments][:file_path]) }
end

4. Brute::Tools::SubAgent — an agent as a tool

A SubAgent exposes a tool-shaped facade so a whole agent drops into another agent’s tool list.

Anything else that responds to #name plus #call or #execute is accepted via duck typing.

The Adapter

Brute::Tools::Adapter.wrap(tool) normalizes any of the shapes above into one interface:

adapter.name        # String
adapter.description # String
adapter.params      # { key => { type:, desc:, required: } }
adapter.call(args)  # execute with a string- or symbol-keyed Hash
adapter.to_h        # library-neutral JSON-Schema-ish definition

Brute.tools(list) (a.k.a. Adapter.wrap_all) turns a tool list into the { name_sym => adapter } lookup the execution middleware uses. #to_h is what your run proc reshapes into each library’s tool format:

# advertise Brute's tools to the OpenAI API
def openai_tools(tools)
  Brute.tools(tools).values.map do |adapter|
    d = adapter.to_h
    { type: "function", function: { name: d[:name], description: d[:description], parameters: d[:parameters] } }
  end
end

Each shipped example shows this conversion for its library.

The built-in tools

Brute::Tools::ALL is the full coding toolset:

Name Class Purpose
read FSRead Read files — line ranges, 2000-line / 50 KB caps, binary detection, directory listing
write FSWrite Create or overwrite files
patch FSPatch Find-and-replace edits
remove FSRemove Delete files/directories
fs_search FSSearch Ripgrep content search with glob filter
undo FSUndo Revert the last file mutation (via snapshots)
shell Shell Execute commands (5 min timeout, 50 KB cap, tail truncation)
fetch NetFetch HTTP GET
todo_write / todo_read TodoWrite / TodoRead Task-list scratchpad
question Question Ask the user interactive questions
skill SkillLoad Load a skill on demand

File-mutating tools share a FileMutationQueue (serializes writes to the same file with a fiber-aware mutex; different files proceed in parallel) and a SnapshotStore (records pre-mutation copies that undo restores).

The execution loop

Tools are executed by the ToolPipeline middleware, wrapped in the tool-result loop:

Loop::ToolResult                 (re-invokes while the last message is a :tool result)
  └─ MaxIterations               (guard: sets env[:should_exit])
       └─ ToolPipeline           (advertises tools in; executes calls out)
            └─ run ->(env) { }   (your one LLM completion)

On the way in, ToolPipeline sets env[:tools]. On the way out, it collects the pending tool calls from the last assistant message and runs them, with three guarantees:

  • Concurrent execution — each call runs as an Async::Task inside an Async::Barrier (fiber-based, via the async gem). Results are sorted back into call order before being appended, so the model always sees a stable sequence.
  • Errors become results — an exception inside a tool is captured as "Error: <class>: <message>" and returned to the model rather than crashing the pipeline.
  • Universal truncation — every result passes through Brute::Truncation.truncate (2000-line / 50 KB cap) as a safety net; the full output overflows to a temp file whose path is included. Tools that truncated internally are not double-truncated.

The question tool is excluded from this loop — it’s handled interactively. Each result is appended as a role: :tool message and emitted as a :tool_result event.


This site uses Just the Docs, a documentation theme for Jekyll.