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 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.
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.
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:
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:
- The plan-in-a-file translation. The model's stubbornest habit is writing its plan to a Markdown file instead of calling
commit_plan. A plan-shaped write is also committed as a structured plan event, so the plan card and the planner both see it. The rewrite is disclosed rather than silent. - Architecture drift is surfaced. A write outside the plan's declared files is flagged rather than quietly accepted.
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
| File | Owns |
|---|---|
loop.go | The loop itself. 815 lines, the largest file in the repository. |
run.go | Run lifecycle: start, pause, kill, steer. |
autopilot.go | Autonomous execution against a plan. |
breaker.go | The bash circuit breaker. |
retry.go | Truncation detection and retry classification. |
progress.go | Workspace fingerprinting and stall detection. |
report.go | Plan reports, reconciled against the filesystem. |
resume.go | Compaction briefings. |
reminder.go | The <system-reminder> envelope and its escaping. |
hooks.go | Turn close and distillation. |
mode.go | Mode contracts and their prompts. |
guard.go | The loop's side of guard integration. |