Concepts
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
Section titled “The four ways to define a tool”1. Brute::Tool subclass — how the built-ins are written
Section titled “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)endFor 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:) = ...end2. Inline Hash — the quickest tool
Section titled “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
Section titled “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]) }end4. Brute::Tools::SubAgent — an agent as a tool
Section titled “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
Section titled “The Adapter”Brute::Tools::Adapter.wrap(tool) normalizes any of the shapes above into one interface:
adapter.name # Stringadapter.description # Stringadapter.params # { key => { type:, desc:, required: } }adapter.call(args) # execute with a string- or symbol-keyed Hashadapter.to_h # library-neutral JSON-Schema-ish definitionBrute.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 APIdef 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] } } endendEach shipped example shows this conversion for its library.
The built-in tools
Section titled “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
Section titled “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.