How-To Guides
Sessions
Brute has no Session class. The “session” is just a JSONL file — one message per line — and the SessionLog middleware owns it:
Brute.agent .use(Brute::Middleware::SessionLog, path: "tmp/session.jsonl") .use(Brute::Middleware::Loop::ToolResult) .use(Brute::Middleware::DefaultCompactionPipeline, window: 200_000, compactor: Brute::Turn::CompactionPipeline.new do use Brute::Compaction::Middleware::ToolResults # rewrite older tool output use Brute::Compaction::Middleware::SlidingWindow # drop the oldest turns run Brute::Compaction::Summarize.new(chat_generator) end) .use(Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL)DefaultCompactionPipeline is that same ladder already wired, for agents that
want ordinary compaction without saying all of it:
.use(Brute::Middleware::DefaultCompactionPipeline, window: 200_000, summariser: chat_generator)- On the way in: if the file exists, its messages are prepended to
env[:messages], so this turn continues the prior conversation. - On the way out: the whole log is written back, one
Brute::Message#to_hper line as JSON — skipping the:systemmessage (theSystemPromptmiddleware re-adds it each turn).
Put SessionLog outermost so history loads before the rest of the stack runs and the complete turn is persisted after.
The format
Section titled “The format”Each line is a message’s to_h, and loading is the exact inverse:
Brute::Message.new(**JSON.parse(line, symbolize_names: true))Because Brute::Message symbolizes roles and coerces tool_calls hashes into ToolCall, the round-trip is lossless — a persisted tool-calling turn reloads with its structure intact. A sample line:
{"role":"assistant","content":"","tool_calls":[{"id":"tc1","name":"shell","arguments":{"command":"ls"}}]}Multi-turn
Section titled “Multi-turn”Run the same agent twice against the same path and the second turn sees the first:
agent = Brute.agent .use(Brute::Middleware::SessionLog, path: "tmp/chat.jsonl") .use(Brute::Middleware::SystemPrompt) .run ->(env) { ... }
agent.start("My name is Nathan.")agent.start("What's my name?") # history is loaded; the model has the contextUse a different path per conversation to keep them separate; delete the file to start fresh.
Context growth
Section titled “Context growth”For long conversations, DefaultCompactionPipeline gives up part of the history once
it fills too much of the model’s window. It decides when; a compactor decides
what goes.
Brute::Turn::CompactionPipeline builds that compactor out of middleware, so
the stack order is the policy: a layer that got the conversation under target
never calls the next, which keeps the strategies that cost nothing above the
one that spends a model call.
Brute.agent .use(Brute::Middleware::SessionLog, path: "tmp/session.jsonl") .use(Brute::Middleware::Loop::ToolResult) .use(Brute::Middleware::DefaultCompactionPipeline, window: 200_000, compactors: [ Brute::Compaction::ToolResults.new, # rewrite older tool output Brute::Compaction::SlidingWindow.new, # drop the oldest turns Brute::Compaction::Summary.new(summarize: summarize), ]) .use(Brute::Middleware::DefaultToolPipeline, tools: Brute::Tools::ALL)It belongs inside Loop::ToolResult rather than around it, so it runs before
every call — a long run of tool results can fill the window without the turn
ever ending. It sits inside SessionLog too, so it works against the loaded
history and SessionLog persists what survives.
Compaction is lossy and the pipeline keeps no record of what it gave up. It says so instead, and an application that wants the original preserves it:
agent.on(:compacted) { |env, payload| archive(env, payload) }