Middleware

A turn is a Rack-style pipeline: middleware wraps the terminal run proc, each layer seeing the env on the way in and again on the way out. You compose the machinery you want and omit what you don’t.

A middleware is any object constructed with (app, **opts) that responds to call(env) and calls @app.call(env). The convention:

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

Files are numeric-prefixed by stack position (002_session_log.rb, 070_tool_pipeline.rb) — lower numbers sit further out.

The catalog

Middleware Role
SessionLog Loads conversation history from a JSONL file on the way in; persists the whole turn on the way out. Outermost. See Sessions.
SystemPrompt Prepends a :system message (unless one already exists). Defaults to Brute::SystemPrompt.default; pass a custom one for specialized agents.
Loop::ToolResult The agentic loop. Re-invokes the inner stack while the last message is a :tool result, bumping current_iteration. Stops on a text answer or should_exit.
MaxIterations Guards against runaway loops. When current_iteration exceeds the cap (default 100), injects a “Maximum iterations reached” user message so the loop exits naturally.
ToolPipeline Advertises tools on env[:tools] going in; executes the model’s tool calls coming out. See Tools.
Summarize Runs one final tool-free completion after the loop, so the agent ends on a clean text answer.
EventHandler Wraps env[:events] in a handler class (e.g. terminal output). See Events.
Question Interactive-question plumbing (works with the question tool).
Tracing Logs per-call timing and token usage; accumulates timing into env[:metadata][:timing].
CompactionCheck Hook point for context compaction when the conversation grows large.
OTel::* OpenTelemetry spans, token usage, and tool-call events — pure pass-throughs unless opentelemetry-sdk is loaded.

Loop::ToolResult

The standard agentic loop is a do-while: the inner app always runs once, and the condition is checked after each pass.

use Brute::Middleware::Loop::ToolResult

Its condition: continue while env[:messages].last.role == :tool and env[:should_exit] is unset. The generic Brute::Middleware::Loop takes any proc or block condition if you need a different loop:

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

A typical stack

Brute.agent
  .use(Brute::Middleware::EventHandler, handler_class: TerminalOutput)
  .use(Brute::Middleware::SessionLog, path: "tmp/session.jsonl")
  .use(Brute::Middleware::SystemPrompt)
  .use(Brute::Middleware::Loop::ToolResult)
  .use(Brute::Middleware::MaxIterations)
  .use(Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL)
  .run ->(env) { ... }

Order matters: SessionLog outermost so history is loaded before anything else and the whole turn is persisted after; Loop::ToolResult above ToolPipeline so the loop re-runs after each tool batch; MaxIterations between them as the guard.


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