Skip to content

Concepts

The Rack Model for Agent Turns

Brute treats an agent turn the way Rack treats an HTTP request. In Rack, a request becomes an env hash that flows down through middleware toward a terminal application, and the response flows back up through the same layers. In Brute, the turn is the request, env is the same kind of hash, and the terminal app is your LLM call:

.start(prompt)
▼ env flows down ──────────────────────────────────▶
SessionLog load history from disk
SystemPrompt prepend the system message
Loop::ToolResult ┐ re-invoke while the model
MaxIterations │ keeps calling tools
ToolPipeline ┘ advertise tools; execute calls
run ->(env) { } ← YOUR one LLM completion
▲ results flow back up ◀───────────────────────────

The middleware is the agentic machinery — persistence, prompting, the tool loop, iteration guards — and it is composable: use what you want, omit what you don’t. Nothing in the stack knows which LLM library sits at the bottom.

A middleware is any object constructed with (app, **opts) that responds to call(env) and calls @app.call(env) exactly once — before for “on the way in” work, after for “on the way out” work:

class MyMiddleware
def initialize(app, **opts) = (@app = app)
def call(env)
# ... before the rest of the stack ...
@app.call(env)
# ... after ...
env
end
end

Because every layer sees the same mutable env, middleware communicate by leaving values in it: the system prompt appears as a message, tools as env[:tools], timing and session ids in env[:metadata]. Files are numeric-prefixed by stack position (002_session_log.rb, 070_tool_pipeline.rb) — lower numbers sit further out.

The agentic loop — call the model, run its tools, call again — is itself just middleware (Loop::ToolResult). It is a do-while: the inner stack always runs once, then the loop re-invokes while the last message is a :tool result (or until env[:should_exit] or MaxIterations ends it). Your run proc does one completion per pass, never the whole loop — Brute stays the turn manager.

This placement is what makes the framework small: there is no loop object to configure, no callback API. A different loop is a different condition proc:

use Brute::Middleware::Loop, ->(env) { env[:messages].last&.role == :tool }

Middleware ordering is not decoration — it is where behaviour lives. SessionLog goes outermost so history loads before anything else and the whole turn persists after. Loop::ToolResult sits above ToolPipeline so the loop re-runs after each tool batch, with MaxIterations between them as the guard. Compaction sits inside the loop so it runs before every model call, not once per turn.

The same principle scales down into sub-pipelines: ToolPipeline builds a single tool out of middleware (validate → snapshot → execute), and CompactionPipeline builds a compactor out of strategies ordered cheapest-first — a layer that already got under target never calls the next, so stack order is the cost policy. One mental model all the way down.