Skip to content

Runner

zkit/agent/runner implements a single method:

func (r *Runner) Run(ctx context.Context, spec TaskSpec) TaskResult

and that method is the entire think → call tools → observe → repeat loop. The shape, per iteration:

wait on conversation lock (yield to a real-time conversation)
drain steerer queue (inject user messages queued mid-task)
maybe inject finalize-warn (when iter is near the cap)
maybe compact (gated by a cheap Prober pre-check)
shape messages via chat template (Qwen3 / Gemma4 / passthrough)
stream Provider.Complete (watchdogs on iteration + stream-idle)
append assistant message
if tool calls: dispatch each through the registry, append results
else: terminal — exit with TerminalCompleted
progress callback (persist per-iter state)
r := runner.New(client,
runner.WithTools(source), // ToolSource, re-read every iter
runner.WithPromptText("You are…"), // or WithPrompt(PromptSource)
runner.WithMaxIterations(20),
runner.WithToolTimeout(45*time.Second),
runner.WithIterationTimeout(5*time.Minute),
runner.WithStreamIdleTimeout(90*time.Second),
runner.WithCompactor(compact.NewTiered(32_000)),
runner.WithSink(mySink), // typed event stream
)
res := r.Run(ctx, runner.TaskSpec{Prompt: "fix the failing test"})
if res.Err != nil {
return res.Err
}

client is a runner.Client — a one-method narrowing of llm.Provider produced by runner.ClientFromProvider. It stays one method on purpose; if you need more from the LLM, that belongs elsewhere.

A runner with no sink, no prompt source, and no compactor still runs. The loop just emits no events, sends no system message, and never shrinks history — useful for headless background tasks and tests.

Run returns one TaskResult. Operational failures are reported in Err, and Reason says why the loop ended; there is no separate error return:

Reason Meaning
TerminalCompleted the model stopped calling tools — its idea of done
TerminalMaxIterations budget exhausted
TerminalCancelled ctx cancelled (user stop, early-stop watcher)
TerminalError unrecoverable provider or compaction error

A terminal reason is a fact about the run, not the task. Whether the work is actually done is pursue’s job.

A textbook ReAct loop dies in production within the hour. The differences that matter:

Pull-based sources. Prompt, tools, steered messages, and compaction policy are re-read every iteration. Live reload is automatic — there is no cache to invalidate.

A watchdog hierarchy. The iteration timeout caps total wall time per turn; the stream-idle timeout caps the silence between chunks. Each fires a distinct sentinel error, so “we cut it off” is distinguishable from “the connection died” and from “the user hit ctrl+c”. Small local models love to think forever without emitting — without the watchdogs that looks identical to a hang.

Direct synchronous stream consumption. Complete constructs a fully lazy llm.CompletionStream; provider setup and I/O start only when the runner invokes it. The runner processes each yield synchronously and returns the downstream boolean directly, preserving backpressure and borrowed chunk lifetimes. There is no forwarding channel or producer goroutine. Provider cancellation is part of the contract, while the iteration/idle wrapper owns and joins its watchdog and preserves the context cause; time spent in the runner’s yield callback does not count as provider idle. Successful EOF completes the stream, and a terminal failure is one zero-chunk error yield—never a Done chunk or outer setup error.

Soft recovery for malformed tool JSON. llama.cpp validates tool-call JSON server-side and 500s before the runner ever sees the arguments. The runner pattern-matches that failure, injects a corrective user message (“your tool-call JSON was malformed — re-emit with proper escaping”), and continues — capped at three consecutive attempts so a permanently confused model can’t loop forever.

The finalize warning. Near the iteration cap the runner injects a one-shot “you have N iterations left — wrap up” message. Models otherwise explore right through iteration 19 of 20 and run out of budget with nothing to show.

Steering and the conversation lock. WithSteerer lets a UI queue user messages mid-task; the runner drains the queue at the top of each iteration. WithConversationLock makes a long-running background task yield while a real-time conversation is happening, then resume.

Per-iteration progress persistence. WithProgressUpdater fires after every dispatch with (iter, totalToolCalls). If the process gets SIGKILLed at iteration 46, the record says iteration 46 — not the zero state from the initial insert.

Typed, exhaustive events. WithSink(EventSink) streams the run — content deltas, tool start/finish, compaction, steering. See Observing a run below.

The runner executes up to four consecutive workspace reads concurrently. This includes the built-in file reads, directory listings, grep, and glob tools. Writes, shell commands, and tools without read-only workspace metadata act as ordered barriers: earlier calls finish before they start, and later calls wait until they finish. Results retain the model’s original call order.

This default is shared by live turns, subagents, and evaluations. Set runner.WithToolConcurrency(1) to force sequential execution. Explicit values above one allow all tool calls to overlap, so the caller must ensure their independence and coordinate workspace access. For the evaluation CLI, --tool-concurrency 0 selects the automatic default and 1 forces sequential execution. At the runner API level, omitting the option selects the default; explicit WithToolConcurrency(0) retains sequential execution.

WithSink is how you watch a run happen — render a TUI, log telemetry, drive a progress bar. EventSink is eight small sub-sinks composed together:

Sub-sink Fires on
ContentSink each streamed assistant-text delta
ThinkingSink each streamed reasoning delta
ToolSink tool started / completed / failed
WorkspaceWaitSink workspace wait started / ended
ConversationSink run started / ended, each iteration, and provider attempt settlement
SteerSink queued user messages injected mid-run
CompactionSink history compaction applied
DiagnosticSink recoverable operational decisions

Every callback takes context.Context first, followed by its typed event. The context preserves the publishing operation’s values, deadline, and cancellation, so observers can correlate events with tracing spans. Wrappers forward it unchanged. Terminal events still arrive after cancellation; classify outcomes from the event, since successful cleanup can also cancel an operation’s context. Extract needed correlation metadata during the callback rather than storing contexts in queued messages. This contract is shared by tools.WorkspaceWaitObserver.

Existing custom sinks must update their method signatures to accept context; direct callers must pass the operation context as the first argument.

You rarely want every method. Embed runner.NopSink and override only the events you care about — it satisfies the whole interface with no-ops, so this is a complete, valid sink:

// Print a line whenever a tool runs; ignore everything else.
type toolLogger struct{ runner.NopSink }
func (toolLogger) OnToolStarted(_ context.Context, e runner.ToolStarted) {
fmt.Printf("→ %s %v\n", e.ToolName, e.Parameters)
}
func (toolLogger) OnToolCompleted(_ context.Context, e runner.ToolCompleted) {
fmt.Printf("✓ %s\n", e.FormattedResult)
}
r := runner.New(client,
runner.WithTools(reg),
runner.WithSink(toolLogger{}),
)

runner.NopSink{} on its own is the do-nothing sink — the default when you pass no WithSink at all, and useful as an explicit “discard all events” in tests.

Embedding is what makes the exhaustiveness contract bearable: a full EventSink implementation (no embed) fails to compile the day a new event method is added, which is the point — a real UI shouldn’t silently drop a new event kind. Consumers that genuinely want to ignore the future opt out by embedding NopSink, and say so by doing it.

WithPromptText is the 90% case. For live-editable system prompts, implement PromptSource — it’s consulted at the start of every Run, and runner.StaticPrompt(body) adapts a plain string. TaskSpec.PromptVars threads per-task variables (persona prefix, user name, whatever your template needs) into the source.

The runner consults its Compactor between iterations, gated by the Prober pre-check so no-op compactions cost nothing. Keep-window sizing is either static (WithCompactKeepRecent) or adaptive (WithAdaptiveKeepRecent(targetTokens, minKeep, maxKeep)), and WithTokenPressureCompact adds a budget-fraction trigger. The engines themselves are covered in Compaction.

The options above are the common ones. A handful of additional knobs cover specific failure modes observed in long-running sessions:

MemoSource. Wraps a tool source and memoises tools that declare themselves pure (read, ls, grep, glob). A re-read of the same path returns cached bytes without touching the tool — or the guardrail chain — and stops repeated identical reads from eating a fan-out budget. Entries drop per task on completion.

ThinkingBudget. Caps the total bytes of reasoning a model can emit without producing content. Local models sometimes think forever without answering — this cuts the loop when reasoning alone fills the budget, before the iteration timeout fires.

EmptyStreamBackoff. When a provider returns zero chunks (not uncommon on overloaded local endpoints), the runner retries with exponential backoff rather than surfacing an opaque error.

Truncator. Controls how tool results are trimmed before they enter the conversation. The default keeps up to 2000 lines within a 200KB byte budget and spills the full output to disk when either limit is exceeded — the model sees a head/tail excerpt with a path to the full file.

ToolGate. Run-scoped capability filtering. A researcher profile might hide every workspace-mutating ToolSpec; a deeper child might hide agent_spawn. WithToolGate(ctx, fn) stores a func(tools.ToolSpec) bool on the run context. Rejected tools are omitted from the model-visible surface and refused if called.

TurnQuality and CompletionGate. Both are consulted when an iteration has no tool calls and would otherwise complete. TurnQuality can reject a degenerate assistant turn and inject a corrective user message. CompletionGate independently checks whether required durable work happened before allowing the terminal exit. They are consumer extension points, not pre-dispatch hooks.

runnertest. A deterministic fake client for testing agent wiring. runnertest.NewClient replays a scripted sequence of turns — no API key, no network, no flakes. Every example in the repo leans on this to run end-to-end with -scripted and no LLM at all.