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::Taskinside anAsync::Barrier(fiber-based, via theasyncgem). 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.