cerveau docs site github
How it works

The agent loop

Twelve files in internal/loop, and the only package that coordinates. Everything else is a mechanism it calls. This is also where the harness's most specific knowledge lives: the exact ways a small model wastes a run, and what stops each one.

One iteration

The loop runs until the model answers rather than calling a tool. Each pass assembles context, packs the window, calls the model, and dispatches whatever comes back. What makes it more than a for loop is everything below.

The bash circuit breaker

A model that hits a missing dependency does not experience it as a wall. Each retry is a fresh idea to it — a different flag, a different package name, a different path — so it can spend an entire run circling one absent thing.

The run this exists because of A benchmark build: 43 bash calls, 1 write, no result. Twenty-six of those calls hunted for Playwright or Puppeteer, which Cerveau has never had. Nothing in the harness ever said "the thing you are reaching for is not here."

The breaker counts failures by shape rather than by exact command, because the model varies the command every time while the underlying failure stays identical. At three of a shape, the tool stops returning an error and starts returning a question — and the counter resets, so it is one prompt per streak rather than one per call.

The bash circuit breaker Three failing commands that differ in wording but share a failure shape are counted together. At the third, the tool stops returning an error and returns a question instead. npx playwright test not found pip install puppeteer not found which chromium not found one shape count reaches 3 returns a question naming check_page, not another error COUNTS BY SHAPE, NOT COMMAND — THE WORDING CHANGES, THE WALL DOES NOT
The run this exists because of: 43 bash calls, one write, no result. Twenty-six of them hunted a browser driver that was never installed.

Progress detection

The guard counts tool errors, which makes calm, confident uselessness invisible to it. The two worst benchmark runs both had almost no errors at all.

What separates those from real work is not error count, tool count, or duration. It is whether the artifact changed. A model that writes files is working however slowly; a model that has not touched the workspace in eight iterations is circling.

workTracker fingerprints the workspace each iteration. progressStallLimit is 8 — how many consecutive iterations may leave the workspace byte-identical before the turn is stopped.

Why 8 and not 3 Deliberately loose. Reading, grepping and planning are legitimate non-writing work, and a build can reasonably spend several turns investigating before it edits. The runs this catches blew past twenty such iterations, so 8 catches them with a wide margin rather than clipping careful work.

Retry classification

Not every failure means the same thing. The loop separates a model that got the arguments wrong from a model that was cut off mid-sentence.

isTruncatedToolCall spots llama.cpp's server-side failure to parse a tool call whose JSON arguments were cut off at the token cap — the generation hit max_tokens mid-string, so the JSON has no closing quote or brace. That is recoverable: the model does not need to rethink, it needs to split the write into smaller calls, and it is told exactly that.

looksTruncated makes the finer distinction — arguments that are cut-off JSON rather than merely wrong JSON. Telling a model to fix its syntax when the real problem was a token cap sends it in circles.

This pairs with each tool's RetryClass: args means fix the call, transient means the world was briefly wrong. See Tools.

Plan reports

BuildReport derives step status from checkpoint events. BuildReportAt also consults the filesystem when a workspace is available, and the reason is subtle:

Checkpoints are written only when a step completes. A step cut short by a guard denial leaves real files on disk and no completion event — so an event-only report shows "pending" for work that is plainly done. Reconciling against the filesystem at the source means the chat's plan strip and the planner panel agree, rather than each doing its own reconciliation and disagreeing.

Resume briefings

After a compaction the model has lost the turns that carried the original request. A bare marker says history went and suggests re-reading files — which is a hint, not a briefing. A model that lost the goal cannot re-derive it from a file listing.

buildResumeBrief carries what is not recoverable by reading the workspace:

Goal
The user's original request.
Plan
The committed plan title, if any.
Done
Completed steps and checkpoints.
Files
What is on disk right now.
Workspace
The absolute path.
Compacted
How many turns were folded away.

It is wired into the window manager through SetResumeBrief, so compaction replaces dropped turns with this rather than with a generic marker. With nothing known it degrades honestly — an empty section rather than a confident invention. See Context window.

Autopilot

A plan committed in Discussion is injected as guidance, not as a script: the agent follows its intent and adapts. No plan is fine — it plans as it goes.

Two behaviours worth knowing:

Telling the model what is already running

The loop injects the live stack into the system prompt, built from config rather than hardcoded:

Local stack ALREADY RUNNING on this machine (never serve anything on
these ports): Cerveau API on localhost:7700, LLM server (llama.cpp) on
http://localhost:8080, embedder on http://localhost:8081, Typesense on
http://localhost:8108. When you need to serve something you build, pick a
free port such as 8000, 3000, or 8888 — and check it's free first.

Without it, an agent asked to serve a page reaches for 8080 and takes down the model it is running on.

Turn close

hooks.go runs the grammar-constrained distill after the answer has already shipped. It builds the GBNF grammar from turnCloseSchema, calls the model with a 1024-token cap, and hands the result to the curator. A parse failure degrades to an empty result — grammar-constrained output can still be truncated at the cap, and the turn must not fail because note-taking did. See Memory.

The files

FileOwns
loop.goThe loop itself. 815 lines, the largest file in the repository.
run.goRun lifecycle: start, pause, kill, steer.
autopilot.goAutonomous execution against a plan.
breaker.goThe bash circuit breaker.
retry.goTruncation detection and retry classification.
progress.goWorkspace fingerprinting and stall detection.
report.goPlan reports, reconciled against the filesystem.
resume.goCompaction briefings.
reminder.goThe <system-reminder> envelope and its escaping.
hooks.goTurn close and distillation.
mode.goMode contracts and their prompts.
guard.goThe loop's side of guard integration.
A pattern worth noticing Four of these — the breaker, progress detection, retry classification, report reconciliation — exist because of a specific observed failure, and the comment above each records the run that caused it. That is what "built for small models that make mistakes" means in practice: not a slogan, but a file per failure mode.