runner

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 26 Imported by: 0

README

zkit/agent/runner

The canonical agent loop — think → call tools → observe → repeat — as a transport-agnostic, drop-anywhere package. The same code drives the zarlcode TUI (zarlcode/tui).

Six concerns, nothing else

The runner depends on six small consumer-implemented interfaces; everything else is pushed onto the consumer side.

  1. LLM clientClient (single method, streaming via iter.Seq2[Chunk, error]).
  2. The loopRunner.Run(ctx, TaskSpec) (TaskResult, error).
  3. Dynamic tool listToolSource, re-snapshotted every iteration.
  4. Live-reloadable system promptPromptSource, called at the start of every Run.
  5. Event sinkEventSink composite (5 sub-sinks), one method per event type.
  6. Compaction policyCompactor, called between iterations to shrink history.

Optional plumbing: Steerer (queued user messages), ConversationLock (yield to a real-time conversation), Truncator (cap oversized tool results).

Quick start

client := runner.ClientFromProvider(myLLMProvider)   // wraps an llm.Provider
toolReg := tools.NewRegistry()
toolReg.Register(myTool)

r := runner.New(client,
    runner.WithTools(toolReg),
    runner.WithSink(myEventSink),
    runner.WithPrompt(runner.StaticPrompt("You are a helpful assistant.")),
    runner.WithMaxIterations(20),
)

result, err := r.Run(ctx, runner.TaskSpec{
    Prompt: "summarise today's news",
})

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.

Live reload

Every state a consumer wants to mutate at runtime flows through a pull-shaped boundary:

  • Tools: ToolSource.Tools() returns iter.Seq[tools.Tool] — the runner re-reads every iteration. Register a tool mid-run and it's callable on the next turn.
  • System prompt: PromptSource.System(ctx, vars) is called at the start of every Run. A source backed by a watched file or a database row picks up changes between turns automatically.
  • Steered messages: Steerer.Drain(ctx) returns an iter.Seq[llm.Message] at the top of every iteration. An interactive harness (or the MCP notification bridge in zkit/agent/mcp) injects fresh user messages without restarting the loop.
  • Compaction: Compactor.Compact(ctx, messages, lastUsage) is called at the start of every iteration after the first. The compactor decides whether the next request would overflow and returns a shrunken history.

No watchers, no broadcast machinery. The runner asks; the source answers fresh.

Key types

Sentinel errors

Consumers errors.Is against TaskResult.Err (or the error returned from Run) instead of parsing strings:

  • ErrInvalidIterationsTaskSpec.MaxIterations was negative.
  • ErrCancelled — the run was cancelled mid-loop (wraps ctx.Err()).
  • ErrPromptRender — the PromptSource returned an error.
  • ErrCompact — the Compactor returned an error.

Testing

Use zkit/agent/runner/runnertest for shared fakes — a scriptable Client, recording Sink, minimal Tool, and chunk constructors — so test files don't reinvent them.

Where to look next

  • AGENTS.md — design rationale, integration patterns, and what not to do.
  • zarlcode/tui/ — the canonical consumer; see shell.go:rebuildRunner for full wiring.

Documentation

Overview

Package runner implements the core agent loop.

A Runner renders prompts, streams model output, dispatches tool calls, publishes structured events, handles compaction/truncation, and supports interactive steering. The package keeps its contracts small so consumers can provide their own clients, tool sources, sinks, and prompt sources.

Memoizing tool source: caches results from pure-read tools within the scope of a single Run, eliminating intra-turn re-reads (the "read the same file three times in a row" pattern) without stale-cache risk.

Scope is per-task: each Run gets its own bucket keyed by taskscope.ID (planted on ctx by the runner). When the task ends the bucket is dropped on the next call — no cross-task pollution, no manual invalidation. When a successful mutating tool call lands inside the same task (for example write / edit / write_append / apply_patch), MemoSource proactively clears that task's pure-tool bucket so a subsequent read sees fresh workspace state. Tools that mutate state must NOT be marked pure; only declare a tool pure when (args ⇒ result) holds for the duration of one user-level turn between mutations. result) holds for the duration of one user-level turn.

Package runner provides the canonical agent loop — think → call tools → observe → repeat — as a transport-agnostic, drop-anywhere library.

Every concern is pushed onto a small consumer-implemented interface:

  • Client — LLM streaming completion.
  • ToolSource — the live tool list + dispatcher.
  • PromptSource — system prompt resolution (live-reloadable).
  • EventSink — observability for content / tool / conversation / steer / compaction events.
  • Steerer — queued user messages between iterations.
  • Truncator — tool-result trimming policy.
  • [Compactor] — conversation-history compaction policy.

What lives in this package is the loop body plus the types those interfaces use (TaskSpec, TaskResult, event payloads, sentinel errors).

All tools the LLM sees are normal registry tools — there is no "action tool" classification. The runner exposes whatever the installed ToolSource yields each iteration. Consumers that want sub-agent recursion register zkit/agent/tools/spawn.New as one of those tools; the runner ships none.

Construction is options-driven via zkit/options:

r := runner.New(client,
    runner.WithTools(toolRegistry),
    runner.WithSink(sink),
    runner.WithPrompt(promptSource),
    runner.WithSteerer(steerer),
    runner.WithCompactor(compactor),
    runner.WithMaxIterations(20),
    runner.WithToolConcurrency(4),
)
result, err := r.Run(ctx, runner.TaskSpec{
    Prompt: "summarise today's news",
})

The loop body lives in run.go; this file is types and construction.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidIterations is returned when TaskSpec.MaxIterations is
	// negative.
	ErrInvalidIterations = errors.New("runner: invalid max iterations")

	// ErrCancelled wraps the ctx.Err() when a Run was cancelled mid-loop
	// (typically by the ConversationLock yielding to a real-time
	// conversation that was cancelled in turn). Surfaced as
	// TaskResult.Err with Reason = TerminalCancelled.
	ErrCancelled = errors.New("runner: run cancelled")

	// ErrTaskIDActive is returned when another Run on the same Runner already
	// owns the requested non-empty TaskSpec.ID. Completed IDs may be reused.
	ErrTaskIDActive = errors.New("runner: task id already active")

	// ErrPromptRender wraps a PromptSource.System failure. Surfaced as
	// TaskResult.Err with Reason = TerminalError before iteration 0.
	ErrPromptRender = errors.New("runner: prompt render")

	// ErrCompact wraps a Compactor.Compact failure. Surfaced as
	// TaskResult.Err with Reason = TerminalError.
	ErrCompact = errors.New("runner: compact failed")

	// ErrIterationTimeout is the per-iteration timeout firing. Wraps
	// the streaming context's cancellation reason so consumers can
	// distinguish "we cut it off intentionally" from "the outer ctx
	// died". Surfaced as TaskResult.Err with Reason = TerminalError.
	ErrIterationTimeout = errors.New("runner: iteration timeout")

	// ErrStreamIdle is the stream-idle-timeout firing — the LLM
	// stopped emitting chunks for longer than the configured idle
	// budget. Wraps the underlying ctx cancel reason.
	ErrStreamIdle = errors.New("runner: stream idle timeout")

	// ErrThinkingBudget fires when an iteration emits only reasoning
	// (thinking) tokens past the configured byte budget without producing
	// any visible content or tool call — the degenerate "stuck thinking"
	// loop. Unlike the wall-clock timeouts it's content-aware, so it cuts
	// a runaway reasoning dump WITHOUT killing a healthy long generation
	// that streams real output. Recovered like an empty turn: the runner
	// injects a "stop reasoning, answer or call a tool" nudge and retries,
	// bounded by thinkingBudgetRecoverLimit.
	ErrThinkingBudget = errors.New("runner: thinking-only budget exceeded")

	// ErrEmptyStream is the terminal signal we synthesize when the
	// provider opened the completion stream cleanly (HTTP 200) but
	// closed it without emitting any content, tool call, or decodable
	// terminating frame — the SDK's SSE decoder surfaces this as an
	// EOF-class error (see isEmptyStreamDecodeError). DeepSeek's hosted
	// gateway does exactly this on heavy prefill: it accepts the
	// request, stalls past its first-token deadline on a large context,
	// then cuts the stream empty (observed as "stream: unexpected end
	// of JSON input" with zero content bytes). The failure is transient
	// — a retry, whose prefill the provider has usually cached, almost
	// always succeeds — so consumers can errors.Is this and retry
	// rather than treating it as a real terminal error.
	ErrEmptyStream = errors.New("runner: provider returned empty stream")

	// ErrUpstreamToolCallJSON is the soft-recoverable signal we
	// synthesize when the upstream LLM server rejects the model's
	// tool-call arguments as malformed JSON (llama-server's --jinja
	// path is the common offender — it validates tool-call args
	// server-side and returns 500 instead of letting our downstream
	// repair.Unmarshal recover them). The runner doesn't terminate
	// on this class of failure; it injects a corrective user message
	// asking the model to re-emit with proper escaping and continues.
	// Caps at runner.toolCallJSONRecoverLimit consecutive recoveries
	// per task to prevent looping on a model that can't produce
	// valid JSON at all.
	ErrUpstreamToolCallJSON = errors.New("runner: upstream rejected tool call args as malformed JSON")
)

Sentinel errors. Consumers can errors.Is them against TaskResult.Err (or the error returned from Run) to react to specific terminal states without parsing strings.

View Source
var StderrSink = ToolProgressSink{W: os.Stderr}

StderrSink is a ToolProgressSink that writes tool progress to os.Stderr. It is the default EventSink for a Runner constructed without WithSink.

View Source
var StdoutSink = ToolProgressSink{W: os.Stdout}

StdoutSink is a ToolProgressSink that writes tool progress to os.Stdout.

Functions

func WithAdaptiveKeepRecent

func WithAdaptiveKeepRecent(targetTokens, minKeep, maxKeep int) options.Option[Runner]

WithAdaptiveKeepRecent enables token-budget-aware keepRecent sizing. On every Compact call the runner walks the history tail-first, keeping messages until the running token estimate hits targetTokens, then clamps to [minKeep, maxKeep]. This solves the "static 4 messages" problem: a single huge tool result no longer dominates the keep window, and short narrative turns no longer starve the agent of recent memory.

Reasonable defaults for a 32k-window model: (8000, 2, 12). Smaller windows want smaller targets. Pass minKeep=0 / maxKeep=0 to use safe defaults (2 / 20). targetTokens <= 0 falls back to the static static keep value (disables adaptive).

Mutually exclusive with WithCompactKeepRecent — last-write-wins.

func WithCompactKeepRecent

func WithCompactKeepRecent(n int) options.Option[Runner]

WithCompactKeepRecent overrides the per-iteration compactor's keep-recent count. The compactor receives this on every Compact call (alongside the live message history); it represents the number of most-recent messages the engine must preserve verbatim. Default is 4 — conservative enough that a typical "last assistant turn + its tool results" window survives compaction. Bump for heavier-context workloads where the agent needs broader recent memory across compactions.

Mutually exclusive with WithAdaptiveKeepRecent — last-write-wins.

func WithCompactor

func WithCompactor(c compact.Compactor) options.Option[Runner]

WithCompactor installs a Compactor the runner consults at the start of every iteration after the first. Without this option the runner never auto-compacts; consumers handle context-window pressure at the REPL level (catch-and-retry, /compact slash command, etc.).

func WithCompletionGate

func WithCompletionGate(g CompletionGate) options.Option[Runner]

WithCompletionGate installs the gate the runner consults at the no-tool-call terminal exit. A nil gate (the default) preserves the original "no tool calls == complete" behaviour, keeping the change opt-in for consumers that don't want it.

func WithContextBreakdown

func WithContextBreakdown() options.Option[Runner]

WithContextBreakdown enables the per-iteration per-role history tally on IterationCompleted.Context. It's an O(history) walk + allocation every iteration, so it's off by default — only a consumer that actually renders the breakdown (the TUI's context-window graph) should turn it on; a headless or eval run leaves it off and skips the work.

func WithConversationLock

func WithConversationLock(l *ConversationLock) options.Option[Runner]

WithConversationLock installs the cooperative yield mutex. When set, the runner waits for the lock to become inactive before each iteration so a real-time conversation gets LLM priority.

func WithEmptyStreamBackoff

func WithEmptyStreamBackoff(d time.Duration) options.Option[Runner]

WithEmptyStreamBackoff sets the base pause before retrying an iteration that failed with ErrEmptyStream (the provider opened the stream then cut it empty). The pause doubles per consecutive retry, up to emptyStreamRetryLimit retries. The default is 500ms; pass 0 for an immediate retry (used in tests). Unlike the timeout options this accepts 0 as a real value rather than a no-op.

func WithFinalizeWarn

func WithFinalizeWarn(fw FinalizeWarn) options.Option[Runner]

WithFinalizeWarn installs a cap-warning nudge configuration. A FinalizeWarn with RemainingThreshold <= 0 (the default zero value) silently disables the feature — useful for headless runs where the cap exists purely as a watchdog and a wrap-up nudge would add noise rather than signal.

func WithIterationTimeout

func WithIterationTimeout(d time.Duration) options.Option[Runner]

WithIterationTimeout caps the LLM call + stream drain of a single iteration. It does NOT bound tool dispatch — that's WithToolTimeout's job (see the timeouts type). The default is 5 minutes; pass 0 to disable. With a non-zero value, an iteration whose stream runs longer than d aborts cleanly with ErrIterationTimeout — far more diagnostic than the "signal: killed" an outer ctx timeout produces.

Tune for the slowest legitimate stream you expect: a small local model stuck in thinking-mode shows up as "no progress after 60s + 10K tokens of streaming", which a 3-5 min cap recovers from without false-positive aborts on the genuinely-long-but-progressing cases (a 70-iter hugo refactor takes ~9 min total but each stream is ≤ a minute).

func WithMalformedToolCallGuard added in v0.2.0

func WithMalformedToolCallGuard(d MalformedToolCallDetector) options.Option[Runner]

WithMalformedToolCallGuard is a convenience option that installs a MalformedToolCallDetector chained ahead of an existing TurnQuality hook, preserving whatever was already configured.

func WithMaxIterations

func WithMaxIterations(n int) options.Option[Runner]

WithMaxIterations sets the default loop cap used when a TaskSpec's MaxIterations is zero. Default is 12.

func WithMaxTokens

func WithMaxTokens(n int) options.Option[Runner]

WithMaxTokens caps each completion request's output tokens (the wire max_tokens). It's a hard, deterministic ceiling on a single generation — unlike the wall-clock iteration timeout, it doesn't depend on the model honoring enable_thinking or on the timer goroutine being scheduled promptly under load. A value <= 0 leaves it unset (the provider/server default applies).

func WithProgressUpdater

func WithProgressUpdater(u ProgressUpdater) options.Option[Runner]

WithProgressUpdater installs a callback the runner fires after every iteration's tool dispatch completes. The callback receives the just-completed iteration index and the cumulative tool-call count. Used to write intermediate progress to durable storage (eg. the headless_runs row) so a SIGKILL'd run still leaves a trail showing how far the agent got — without this, RunRecorder's CompleteHeadlessRun never runs and the row stays at its initial "iter=0/tools=0" state regardless of actual progress.

The callback runs synchronously on the runner goroutine. Keep it fast — a single UPDATE statement or a channel send. Network calls will stall the iteration loop.

func WithPrompt

func WithPrompt(p PromptSource) options.Option[Runner]

WithPrompt installs a PromptSource the runner consults at the top of every Run for the system message. Pull-based by design — the source is free to re-read its underlying state on each call so edits to a prompt file (or database row) take effect on the next turn without restarting the runner. Without this option the runner sends no system message at all.

For the common case of a fixed prompt string, use WithPromptText.

func WithPromptText

func WithPromptText(prompt string) options.Option[Runner]

WithPromptText is a shorthand for WithPrompt(StaticPrompt(prompt)).

func WithResultTruncator

func WithResultTruncator(t Truncator) options.Option[Runner]

WithResultTruncator installs the policy for capping oversized tool results. Defaults to DefaultTruncator (trim only, no spill). The zarlcode installs SpillingTruncator so the agent can re-read the original transcript via bash.

func WithSink

func WithSink(s EventSink) options.Option[Runner]

WithSink installs the event sink the runner publishes lifecycle and diagnostic events to. Passing nil is invalid configuration and panics.

func WithSteerer

func WithSteerer(s Steerer) options.Option[Runner]

WithSteerer installs a Steerer the runner consults at every iteration boundary. Without this option the runner runs unsteered (current behaviour).

func WithStreamIdleTimeout

func WithStreamIdleTimeout(d time.Duration) options.Option[Runner]

WithStreamIdleTimeout caps the gap between consecutive chunks from the LLM stream. The default is 60 seconds; pass 0 to disable. Use to catch genuinely dead connections (provider hang) without bailing on legitimate long-running responses. Independent of iteration timeout: a stream that emits one chunk every 30s for 10 minutes is fine for idle timeout but trips iteration timeout.

func WithTemperature

func WithTemperature(t float32) options.Option[Runner]

WithTemperature sets the sampling temperature on each completion request. t <= 0 leaves it unset (the request omits temperature, so the provider / server default applies). A low value (e.g. 0.2) improves determinism and tool-call reliability for local models.

func WithTemplate

func WithTemplate(t templates.ChatTemplate) options.Option[Runner]

WithTemplate selects the chat template (Qwen3, Gemma4, etc.). The template handles per-model wire-format quirks — sentinel injection, thinking-mode kwargs, and so on — that local-model backends like Ollama need but managed APIs like Anthropic and OpenAI handle internally. The default is templates.Qwen3{}, picked because the runner originated against a local Qwen install; managed-API consumers can leave the default in place since their providers don't consult these template hooks.

func WithThinkingBudget

func WithThinkingBudget(byteBudget int) options.Option[Runner]

WithThinkingBudget cuts an iteration that has streamed only reasoning (thinking) tokens past byteBudget without yet emitting any visible content or tool call — the degenerate "stuck thinking" loop. The cut is recovered like an empty turn (a "stop reasoning, act now" nudge, then a retry) up to thinkingBudgetRecoverLimit. Being content-aware, it spares a healthy long generation that streams real output. A value <= 0 disables the cut.

func WithTokenPressureCompact

func WithTokenPressureCompact(budget int, fraction float64) options.Option[Runner]

WithTokenPressureCompact installs a force-compact threshold keyed to the provider's reported prompt-token usage. When the previous turn's PromptTokens ÷ budget ≥ fraction, the runner skips the Prober gate, shrinks keepRecent to 1, and calls Compact unconditionally.

This complements (does not replace) the engine-side byte-pressure thresholds. Engines that estimate "no work to do" from raw bytes can underestimate real context cost — a 35B-class local model loses structured-output discipline around half its nominal window even when the bytes look fine. The provider's tokenizer is the only reliable signal that we've crossed the *effective* coherent window; this turns it into a trim.

On force-compact the runner shrinks keepRecent to 1 — just the latest message survives — so the latest (often huge) tool result is itself eligible for the engine's most aggressive trim (Tiered's Phase-3 placeholdering) on the next iteration. Trimming only the older slice while a fresh oversized result sits inside the keep window would leave net prompt size unchanged; dropping to 1 breaks that equilibrium. The assistant tool_call metadata pointing at the result survives, so the model still sees what it asked for.

Two ways to express the trigger:

  • window-relative: budget = the model's context window, fraction = the share at which to compact. coderunner.StandardOptions wires this with a shared fraction so the TUI and eval can't diverge.
  • absolute: budget = the empirical token threshold, fraction = 1.0 — the coherence wall is really a property of the *model*, not a percentage of its window. Observed thresholds (nominal window → wall): Qwen3.6-35B (131k) → ~65k Llama-3.1-70B (128k) → ~75k GPT-5.5 (200k) → ~120k Claude-4.6 Sonnet (1M) → ~250k

budget ≤ 0 or fraction ≤ 0 disables the force-path; fraction is clamped to 1.0.

func WithToolConcurrency

func WithToolConcurrency(n int) options.Option[Runner]

WithToolConcurrency caps how many tool calls in a single LLM tool-call batch the runner dispatches in parallel. n <= 1 disables parallelism entirely (sequential dispatch, the safe default). Default is 1.

func WithToolGate

func WithToolGate(ctx context.Context, gate func(tools.ToolSpec) bool) context.Context

WithToolGate scopes a Run — and every tool dispatch within it — to the tools that gate admits. A tool whose name gate rejects is hidden from the per-iteration LLM tool list and refused with a clear result if the model calls it anyway. The spawn-agent tool plants a gate on a sub-agent's Run ctx to enforce its work mode (e.g. explore = read-only) as real policy rather than prompt text. A nil gate disables gating.

The gate receives the full ToolSpec so it can filter by capability (e.g. Mutates) rather than just by name. The gate is read from ctx on each dispatch, so it scopes exactly to the Run it was planted on: a parent's dispatches (ctx without a gate) are unaffected, and the gate doesn't leak past the child Run it wraps.

func WithToolOutputSink added in v0.11.1

func WithToolOutputSink(s ToolOutputSink) options.Option[Runner]

WithToolOutputSink installs a sink that receives each tool result's full, untruncated output before the truncator trims it. Use it to persist a tool-history store. A nil sink is ignored.

func WithToolTimeout

func WithToolTimeout(d time.Duration) options.Option[Runner]

WithToolTimeout caps a single tool dispatch's wall-clock budget. The default ([defaultToolTimeout], 5 minutes) is a balance: long enough for `go test ./...` on a non-trivial project to finish, short enough that a blocking dynamic / MCP tool can't wedge the run. Pass 0 to disable the per-tool cap entirely (the runner then trusts tools to honour ctx.Done — fine for trusted local tooling, not for arbitrary third-party MCP servers).

Implementation note: the cap is applied as a context deadline around tool.Execute, and Execute runs in a goroutine so the runner can stop waiting when the deadline fires. Well-behaved tools see ctx.Done() fire and unwind cleanly. Tools that ignore context keep running past the deadline until they eventually return, but the runner records the timeout in the tool result and subsequent iterations continue unaffected.

func WithTools

func WithTools(source ToolSource) options.Option[Runner]

WithTools installs the ToolSource the runner snapshots each iteration for the LLM's tool list and dispatches against. Pull-based — the source is re-read every iteration, so tools registered mid-run (the agent built one with `register`, an MCP server just connected) become callable on the next turn. A nil source is ignored (the empty-registry default stands), so a runner with no WithTools is a valid tool-less agent.

func WithTurnQuality

func WithTurnQuality(q TurnQuality) options.Option[Runner]

WithTurnQuality installs the TurnQuality hook the runner consults at every iteration after the assistant message is finalised but before the dispatch / exit branching. A nil quality (the default) disables the check entirely, preserving the pre-C1 "no tool calls == exit" behaviour.

Types

type ChainTurnQuality added in v0.2.0

type ChainTurnQuality []TurnQuality

ChainTurnQuality composes several TurnQuality detectors into one, returning the first non-empty decision in order. It lets a consumer stack independent quality guards (malformed-call recovery, empty-response recovery, …) behind the runner's single TurnQuality seam without one detector having to know about the others.

func (ChainTurnQuality) Inspect added in v0.2.0

func (c ChainTurnQuality) Inspect(content string, toolCalls []llm.ToolCall) TurnQualityDecision

Inspect runs each detector in order and returns the first that asks for a correction; a zero decision when none do.

type Client

type Client interface {
	Complete(ctx context.Context, req llm.CompletionRequest) (iter.Seq2[llm.CompletionChunk, error], error)
}

Client is the runner's view of an LLM. Smaller than llm.Provider — the runner only needs streaming completion, not model discovery, image generation, or capability introspection. Implementations are expected to surface mid-stream errors via the second value of the iter.Seq2 yield, not via a field on the Chunk.

func ClientFromProvider

func ClientFromProvider(p llm.Provider) Client

ClientFromProvider narrows an llm.Provider to the runner's Client view. Now that Provider.Complete returns an iter.Seq2, a Provider satisfies Client directly — this is just the explicit narrowing seam (the runner depends on Client, not the wider Provider).

type CompactionApplied

type CompactionApplied struct {
	TaskID         taskscope.ID
	Depth          int
	MessagesBefore int
	MessagesAfter  int
	BytesTrimmed   int
	Engine         string
}

CompactionApplied fires when the runner's Compactor returned a shrunken history OR trimmed bytes in place. Carries the message-count delta and the per-engine BytesTrimmed report so subscribers can show "[compacted: N → M messages, -B bytes]" or similar. Engine is the label the compactor returned ("structural" / "summary") for UI badging.

type CompactionSink

type CompactionSink interface {
	OnCompactionApplied(CompactionApplied)
}

CompactionSink observes when the runner's Compactor returned a shrunken history. MessagesBefore/After let subscribers show the size delta to the user.

type CompletionDecision

type CompletionDecision struct {
	Correction     string
	MaxCorrections int
}

CompletionDecision is the runner-side action requested by a CompletionGate. A non-empty Correction blocks the completion and is injected as a user message before the loop continues. MaxCorrections caps how many times this gate may hold a single Run; zero means unlimited (bounded only by the runner's MaxIterations).

type CompletionGate

type CompletionGate interface {
	Inspect(workDone bool, content string) CompletionDecision
}

CompletionGate guards the "no tool calls → completed" exit. When the model emits an iteration with no tool calls, the runner normally treats that as a clean terminal state. For a task that REQUIRES a durable change — a SWE-bench fix, a refactor — that exit is wrong if the run never actually mutated anything: the result is a confident final message with an empty patch, an attempt silently spent on nothing.

The gate is consulted at exactly that exit. It receives workDone — whether the run has made at least one successful mutating tool call (edit / write / write_append / apply_patch; see ToolSpec.Mutates) — and the finalised assistant content. A non-empty Correction means "do NOT complete": the runner injects the correction as a user message and continues the loop, so the model can make the change within the SAME Run rather than burning the attempt and relying on a downstream re-drive. Bounded by the decision's MaxCorrections so a genuinely stuck model still terminates instead of looping to the cap.

Consulted only on the no-tool-call terminal turn, AFTER any TurnQuality hook has had its chance — TurnQuality catches empty CONTENT, this catches empty WORK. The two are orthogonal: a model can write a fluent "the fix is straightforward" essay (passing TurnQuality) while having edited nothing (caught here).

Limitation: workDone keys on tool capability (ToolSpec.Mutates), so changes made only through `bash` (e.g. `sed -i`) are NOT counted — bash leaves Mutates unset. In eval mode shell_policy already blocks output redirection and the prompt steers to the dedicated edit/write tools, so this is rare; a consumer that needs authoritative coverage should gate on the actual worktree diff instead.

Implementations must be safe for concurrent use — a Runner is reusable across concurrent Runs.

type Content

type Content struct {
	TaskID taskscope.ID
	Depth  int
	Delta  string
}

Content is a streamed assistant-message delta.

type ContentSink

type ContentSink interface {
	OnContent(Content)
}

ContentSink observes streamed assistant-message deltas. The runner emits one OnContent per chunk that carries a Content field (no batching, no joining); subscribers wanting the full final message accumulate themselves.

type ContextBreakdown

type ContextBreakdown struct {
	SystemBytes    int
	UserBytes      int
	AssistantBytes int
	ToolBytes      int

	// SkillBytes / AgentBytes / InstructionBytes are the skill_load /
	// agent_spawn / instruction_load slices of ToolBytes.
	SkillBytes       int
	AgentBytes       int
	InstructionBytes int

	SystemMsgs    int
	UserMsgs      int
	AssistantMsgs int
	ToolMsgs      int
}

ContextBreakdown is the composition of a Run's working history by role, in raw content bytes plus message counts. Tool bytes are further split into the skill_load / agent_spawn content that dominates most coding sessions, so a subscriber can show "how much of the window is reference docs vs delegated summaries vs ordinary tool output". Bytes are raw content bytes; a chars/4 token estimate is the subscriber's to make.

type ConversationEnded

type ConversationEnded struct {
	TaskID taskscope.ID
	Depth  int
	Reason TerminalReason
	Error  string
	Cause  TerminalCause
	// RateLimit is set when the terminal error is (or wraps) a
	// *llm.RateLimitError, so subscribers can render structured timing
	// (retry-after / reset / permanent-quota) without re-parsing Error.
	// Nil for every non-rate-limit outcome.
	RateLimit        *llm.RateLimitError
	Duration         time.Duration
	Iterations       int
	TotalUsage       *llm.Usage
	ParentToolCallID string
}

ConversationEnded is the single terminal event for a Run — it fires exactly once per Run, for every way a Run can end. Reason classifies the outcome (completed, max_iterations, cancelled, or error); Error carries the failure message when Reason is error, and is empty otherwise. Consumers switch on Reason rather than on which method fired, so "the turn finished" can't be mistaken for "the turn succeeded" — a completed answer, a truncated max-iterations turn, a user cancellation, and an unrecoverable fault all arrive here.

TotalUsage / Iterations / ParentToolCallID let subscribers attribute the full token spend of a Run back to its initiator without having to wait for the spawning tool's ToolCompleted (which only carries the child's text summary, not its usage). Top-level Runs leave ParentToolCallID empty; sub-agents echo the field they received on the matching ConversationStarted.

type ConversationLock

type ConversationLock struct {
	// contains filtered or unexported fields
}

ConversationLock is a cooperative mutex with an "active" flag — used by background runners that share an LLM with a real-time conversation pipeline. When a real-time conversation is active, the runner should yield (pause its tool loop) so the conversation gets the LLM's attention. When the conversation ends, the runner resumes.

Cooperative because the runner has to *check* IsActive — nobody preempts it. The expected usage:

if err := lock.Wait(ctx); err != nil {
    return err // ctx cancelled
}
// safe to proceed

Internally the lock uses sync.Cond so Release wakes Wait()ers immediately (no polling). Cancellation is observed via context.AfterFunc.

func NewConversationLock

func NewConversationLock() *ConversationLock

NewConversationLock creates an unlocked ConversationLock.

func (*ConversationLock) Acquire

func (c *ConversationLock) Acquire()

Acquire marks the conversation as active. Subsequent IsActive calls return true until Release is called. Idempotent — multiple Acquires without an intervening Release leave the lock active until the next Release.

func (*ConversationLock) IsActive

func (c *ConversationLock) IsActive() bool

IsActive reports whether a conversation is currently in progress.

func (*ConversationLock) Release

func (c *ConversationLock) Release()

Release marks the conversation as no longer active and wakes any goroutine blocked in Wait.

func (*ConversationLock) Wait

func (c *ConversationLock) Wait(ctx context.Context) error

Wait blocks until the lock becomes inactive or ctx is cancelled. Returns nil on a clean unlock, ctx.Err() on cancellation. No busy-waiting: a Release wakes the goroutine immediately, and ctx cancellation wakes it via context.AfterFunc.

type ConversationSink

type ConversationSink interface {
	OnConversationStarted(ConversationStarted)
	OnConversationEnded(ConversationEnded)
	OnIterationCompleted(IterationCompleted)
}

ConversationSink observes the bookend events around a single Run. Started fires once per Run; Ended fires exactly once after it (carrying the terminal reason). Sub-agent runs (depth > 0) produce their own Started/Ended pairs. OnIterationCompleted fires once per iteration *within* a Run, between Started and Ended.

type ConversationStarted

type ConversationStarted struct {
	TaskID           taskscope.ID
	Depth            int
	Prompt           string
	ParentToolCallID string
	AgentName        string
}

ConversationStarted marks the start of a Run. Prompt carries the task's seed prompt — the same string the caller handed to Run via TaskSpec.Prompt. The TUI stack pane uses it to label each frame with what the (sub-)agent is actually working on; without it the pane could only show depth + opaque task ID.

ParentToolCallID identifies the tool call that initiated this Run (set by spawn-agent for sub-agents). Empty for top-level Runs. The TUI uses it to attribute a sub-agent's events back to the exact parent tool call that spawned it — critical for parallel agent_spawn dispatch where multiple sub-agents are in flight and can't be distinguished by task ID + Depth alone.

type DefaultTruncator

type DefaultTruncator struct {
	MaxBytes int
	MaxLines int
}

DefaultTruncator trims tail-only with no out-of-band spill. The runner's default. MaxBytes/MaxLines of 0 use the package defaults (50KB / 2000 lines).

func (DefaultTruncator) Truncate

func (t DefaultTruncator) Truncate(s, _ string) string

Truncate keeps the tail of s, capped at MaxBytes/MaxLines (50KB / 2000 lines when zero), and appends a footer noting what was cut. The head is discarded — no spill file. Ignores toolName.

type Diagnostic added in v0.11.0

type Diagnostic struct {
	TaskID  taskscope.ID
	Depth   int
	Kind    string
	Message string
	Attempt int
	Limit   int
	Backoff time.Duration
	Err     error
}

Diagnostic reports a non-terminal recovery or operational decision.

type DiagnosticSink added in v0.11.0

type DiagnosticSink interface {
	OnDiagnostic(Diagnostic)
}

DiagnosticSink observes non-terminal recovery decisions.

type EmptyResponseDetector

type EmptyResponseDetector struct {
	// Message is the correction surfaced to the model. Zero value
	// uses defaultEmptyResponseMessage — a generic "make progress"
	// nudge. Override when a specific consumer wants stricter
	// wording (e.g. "produce Answer: <value>" for benchmarks).
	Message string

	// DisableThinkingOnRetry asks the runner to turn off thinking for
	// the correction iteration. Useful for thinking models that emitted
	// only reasoning_content and no visible answer.
	DisableThinkingOnRetry bool

	// MaxCorrections caps detector injections per Run. Zero leaves the
	// retry bounded only by the runner's MaxIterations.
	MaxCorrections int
}

EmptyResponseDetector is the default TurnQuality implementation. It returns a "please make progress" correction when both the content and tool-call slice are empty after thinking is stripped — the precise shape that lets a thinking-budget-capped Qwen turn terminate the loop with nothing to show for it.

The zero value is usable; the empty struct exists so an option caller can install or override the detector by type.

func (EmptyResponseDetector) Inspect

func (d EmptyResponseDetector) Inspect(content string, toolCalls []llm.ToolCall) TurnQualityDecision

Inspect implements TurnQuality. Returns the configured correction when the assistant produced neither content nor tool calls; otherwise returns a zero decision so the runner proceeds with its normal dispatch/exit branching.

type EventSink

EventSink is the composite the runner takes. Adding an event method here breaks every full-EventSink implementer until they handle it — that's the compile-time exhaustiveness contract. Implementers that genuinely want to ignore future events embed NopSink and override only what they care about.

Concurrency

Implementations MUST be safe for concurrent calls. Two scenarios reach the sink from multiple goroutines:

  • WithToolConcurrency(N>1) dispatches tool calls in parallel; each goroutine independently fires OnToolStarted / OnToolCompleted / OnToolFailed.
  • A single Runner servicing concurrent Run calls publishes events for each task on the calling goroutine.

Mutex-guarded slice append, channel send, or atomic counter all work. The runner does not synchronise its publishes; the sink is on the hook for any internal locking. Consumers that don't want to hand-roll that locking can wrap any sink in SyncSink, which serialises every call behind one mutex.

Example

Observation via EventSink. Embed runner.NopSink to opt out of future events; override only what you care about.

package main

import (
	"github.com/zarldev/zarlmono/zkit/agent/runner"
)

func main() {
	type sink struct{ runner.NopSink }
	// (override OnContent / OnToolCompleted / etc on your concrete type)
	_ = sink{}
}

type FinalizeWarn

type FinalizeWarn struct {
	// RemainingThreshold is the iterations-remaining count at which
	// the warning fires. "5" means the warning lands at the start of
	// the iteration where 5 iterations remain (including the current
	// one). Values <= 0 disable the hook.
	RemainingThreshold int

	// Message overrides the default warning text. Use this for
	// benchmark-specific phrasing — GAIA needs "Answer: <value>",
	// SWE-bench needs "produce the diff now", etc. Zero value uses
	// defaultFinalizeWarnMessage, which is a generic "wrap up"
	// nudge that names the actual remaining-iteration count.
	Message string

	// DeadlineGrace is the time-before-context-deadline at which the
	// nudge also fires, even when the iteration threshold hasn't been
	// reached. Zero disables the time-based trigger. When the Run ctx
	// carries a deadline (a task wall-clock budget), this gives the model
	// a "commit now" signal before the deadline cancels it — the
	// iteration threshold alone misses that when iterations are slow.
	DeadlineGrace time.Duration
}

FinalizeWarn configures the cap-warning nudge: when the iteration loop has FinalizeWarn.RemainingThreshold iterations left (including the one about to start), the runner injects a synthetic user message asking the model to wrap up and commit to a final answer or implementation before the cap fires. Fires exactly once per Run; subsequent iterations within the threshold window don't re-inject.

Without this, a small model deep in tool calls gets cut off mid-thought at MaxIterations with no final reply, the TerminalMaxIterations result lands with an empty FinalContent, and downstream consumers (TUI transcript, headless run records, benchmark scorers) have no useful output to attribute to the model. The warning gives the model a clear "this is your last chance" signal so it can produce a final answer in the remaining turns.

Zero value disables the hook — the runner runs exactly as it did pre-C2.

type IterationCompleted

type IterationCompleted struct {
	TaskID taskscope.ID
	Depth  int
	Iter   int
	// Usage is the most recent usage observed across the Run — a proxy
	// for current context occupancy (what token gauges and the
	// PressureGated compaction gate read). Once any iteration has
	// reported usage it never regresses to nil, even when this
	// iteration's provider dropped usage.
	Usage *llm.Usage
	// Delta is this iteration's own reported usage. Nil when the
	// provider omitted usage on this stream (llama.cpp's openai-compat
	// endpoint is known to drop it on the final chunk). Sum Delta across
	// iterations for per-turn flow; read Usage for occupancy.
	Delta *llm.Usage

	// Context is a per-role byte/message breakdown of the working history
	// at this iteration boundary — enough for a subscriber to draw a
	// context-window composition graph without holding the message slice
	// itself (the runner owns it; the slice would alias and race). Nil
	// when the runner didn't compute one. Advisory, like Usage.
	Context *ContextBreakdown
	// ToolSurface describes the exact post-gate tool snapshot sent on this
	// iteration. It is present even when the surface is empty.
	ToolSurface ToolSurface
}

IterationCompleted fires at the end of each iteration of a Run, after content streaming and tool dispatch settle. Usage and Delta split the two things a subscriber can want from token accounting — occupancy and flow. Both are advisory: not every provider emits usage on every stream, so a nil Delta is normal.

type MalformedToolCallDetector added in v0.2.0

type MalformedToolCallDetector struct {
	// Message overrides the correction surfaced to the model. Zero value uses
	// malformedToolCallCorrection.
	Message string
	// MaxCorrections caps detector injections per Run. Zero leaves the retry
	// bounded only by the runner's MaxIterations.
	MaxCorrections int
}

MalformedToolCallDetector is a TurnQuality guardrail that catches a tool call the model emitted as text but malformed badly enough that neither the provider's own recovery nor the runner's text fallback could parse it — the turn arrives with visible content that is a tool-call artifact yet zero structured calls. Left unguarded the artifact leaks into the transcript as prose and the intended tool never runs. Inspect runs only on zero-tool-call turns (the runner gates it), and by that point the recovery pipeline has already tried and failed, so artifact-shaped content here is necessarily an unrecovered call. The detector injects one corrective turn asking the model to re-emit valid JSON, bounded by MaxCorrections.

The zero value is usable.

func (MalformedToolCallDetector) Inspect added in v0.2.0

func (d MalformedToolCallDetector) Inspect(content string, toolCalls []llm.ToolCall) TurnQualityDecision

Inspect implements TurnQuality. It returns a correction when the turn produced no tool calls but the visible content opens with a known tool-call artifact prefix — the signature of a malformed, unrecovered call. Any other turn yields a zero decision so the runner proceeds normally.

type MemoSource

type MemoSource struct {
	// contains filtered or unexported fields
}

MemoSource wraps a ToolSource and memoizes results from tools the PureFn whitelists. Cache hits short-circuit the inner Execute, returning a clone of the original result (with the new call's ToolCallID). Misses dispatch as normal and store the successful result before returning.

Buckets are per-task — keyed by the taskscope.ID planted on ctx by the runner. Calls from outside a Run (direct unit tests of a tool) land in a shared "no task" bucket, which is fine for tests and fine in production: those code paths don't ship the same tool call twice in one go anyway.

Concurrency

MemoSource is safe to share across concurrent Runs. Three layers:

  • The outer `mu` guards bucket creation and the hit-counter map. bucketFor and bumpHit hold it; Execute does not.
  • Each per-task bucket is a cache.MemoryCache whose own RWMutex guards reads and writes — multiple Execute calls on the same bucket interleave safely without holding the outer lock.
  • The Get → inner.Execute → Set sequence in Execute is NOT atomic. Two parallel calls with the same canonical signature in the same task can both miss, both run inner.Execute, then both Set. Result: the underlying tool runs twice (wasted work), but the cache stays consistent (last writer wins, no torn state). This is acceptable for pure tools — by definition the second run produces the same answer — but callers wiring a non-trivial PureFn whitelist for tools with expensive side-effect-free work should know the memoization is best-effort under contention.

func NewMemoSource

func NewMemoSource(source ToolSource, pure PureFn) *MemoSource

NewMemoSource wraps source with per-task memoization for tools pure reports as pure. A nil pure function disables memoization entirely (every call passes through) — useful for tests that want to assert no caching is happening.

func NewMemoSourceWithLedger added in v0.1.3

func NewMemoSourceWithLedger(source ToolSource, pure PureFn, ledger TaskCallLedger) *MemoSource

NewMemoSourceWithLedger wraps source with per-task memoization and records successful pure calls into ledger when non-nil. The same invalidation boundary applies to both cache and ledger: any successful workspace-changing call drops the current task's pure-call evidence.

func (*MemoSource) Execute

func (m *MemoSource) Execute(ctx context.Context, call tools.ToolCall) (*tools.ToolResult, error)

Execute checks the per-task cache for a previous result of this (tool, args) and returns a clone on hit. On miss, dispatches the call and stores the successful result for future calls within the same task.

Failed results (Success=false) are never cached — a transient failure on the first call shouldn't poison the rest of the turn.

func (*MemoSource) ForgetTask

func (m *MemoSource) ForgetTask(id taskscope.ID)

ForgetTask drops memo state for the given task and forwards the lifecycle notification to the wrapped source when it supports the same optional capability.

func (*MemoSource) Tools

func (m *MemoSource) Tools(ctx context.Context) iter.Seq[tools.Tool]

Tools delegates to the inner source. Memoization doesn't change which tools the LLM sees.

type MemoryTaskCallLedger added in v0.1.3

type MemoryTaskCallLedger struct {
	// contains filtered or unexported fields
}

MemoryTaskCallLedger is the in-memory TaskCallLedger implementation used by the production runner stack. Buckets are keyed by taskscope.ID; the zero ID is the shared "no task" bucket used by direct unit tests.

func NewMemoryTaskCallLedger added in v0.1.3

func NewMemoryTaskCallLedger() *MemoryTaskCallLedger

NewMemoryTaskCallLedger builds an empty per-task call ledger.

func (*MemoryTaskCallLedger) Calls added in v0.1.3

Calls returns a copy of the current task's observed-call slice.

func (*MemoryTaskCallLedger) ForgetTask added in v0.1.3

func (l *MemoryTaskCallLedger) ForgetTask(id taskscope.ID)

ForgetTask drops the bucket for id.

func (*MemoryTaskCallLedger) RecordSuccessfulPureCall added in v0.1.3

func (l *MemoryTaskCallLedger) RecordSuccessfulPureCall(ctx context.Context, tool tools.ToolName, args tools.ToolParameters)

RecordSuccessfulPureCall appends one observed pure call to the current task.

type NopSink

type NopSink struct{}

NopSink satisfies EventSink with no-op methods. Embed when you want to opt out of exhaustiveness for a specific consumer.

func (NopSink) OnCompactionApplied

func (NopSink) OnCompactionApplied(CompactionApplied)

func (NopSink) OnContent

func (NopSink) OnContent(Content)

func (NopSink) OnConversationEnded

func (NopSink) OnConversationEnded(ConversationEnded)

func (NopSink) OnConversationStarted

func (NopSink) OnConversationStarted(ConversationStarted)

func (NopSink) OnDiagnostic added in v0.11.0

func (NopSink) OnDiagnostic(Diagnostic)

func (NopSink) OnIterationCompleted

func (NopSink) OnIterationCompleted(IterationCompleted)

func (NopSink) OnSteerInjected

func (NopSink) OnSteerInjected(SteerInjected)

func (NopSink) OnThinking

func (NopSink) OnThinking(Thinking)

func (NopSink) OnToolCompleted

func (NopSink) OnToolCompleted(ToolCompleted)

func (NopSink) OnToolFailed

func (NopSink) OnToolFailed(ToolFailed)

func (NopSink) OnToolStarted

func (NopSink) OnToolStarted(ToolStarted)

type ObservedCall added in v0.1.3

type ObservedCall struct {
	ToolName  tools.ToolName
	Arguments tools.ToolParameters
}

ObservedCall is one successful pure tool call recorded for a task.

type ProgressUpdater

type ProgressUpdater func(ctx context.Context, iter, toolCalls int)

ProgressUpdater receives running counters after each iteration's tool dispatch completes. Used to persist intermediate progress so a SIGKILL'd run can be reconstructed up to the last completed iteration. iter is the just-completed iteration index (0-based, so iter=0 means one iteration finished); toolCalls is the cumulative tool-call count across all iterations so far.

type PromptFunc

type PromptFunc func(ctx context.Context, vars PromptVars) (string, error)

PromptFunc adapts a plain function to the PromptSource interface, for one-line wrappers around an existing renderer.

Example

PromptFunc adapts a closure to PromptSource for sources that need to read external state (a file, a DB row) on each Run.

package main

import (
	"context"
	"fmt"

	"github.com/zarldev/zarlmono/zkit/agent/runner"
)

func main() {
	count := 0
	p := runner.PromptFunc(func(_ context.Context, _ runner.PromptVars) (string, error) {
		count++
		return fmt.Sprintf("turn %d", count), nil
	})
	body, _ := p.System(context.Background(), nil)
	fmt.Println(body)
}
Output:
turn 1

func (PromptFunc) System

func (f PromptFunc) System(ctx context.Context, vars PromptVars) (string, error)

System calls f itself — the prompt is re-rendered on every Run, so a closure over mutable state picks up changes between turns.

type PromptSource

type PromptSource interface {
	System(ctx context.Context, vars PromptVars) (string, error)
}

PromptSource resolves the runner's system prompt. Called once at the top of every Run, so an implementation backed by a watched file or a database picks up changes between turns without the runner needing to know how the source produces its content.

Returning an empty string with a nil error is fine — the runner just skips the system message.

Concurrency

System runs on the runner's goroutine; concurrent Run calls invoke it concurrently. Implementations should be safe for parallel reads — file-backed sources cache or use sync.RWMutex; DB-backed sources are typically already safe.

func StaticPrompt

func StaticPrompt(body string) PromptSource

StaticPrompt returns a PromptSource that always renders the same string, ignoring vars. Handy for tests and for callers that compute the prompt up front and just want to install it.

Example

StaticPrompt is the simplest PromptSource: a fixed body, ignores vars. Good for headless tasks and tests.

package main

import (
	"context"
	"fmt"

	"github.com/zarldev/zarlmono/zkit/agent/runner"
)

func main() {
	p := runner.StaticPrompt("You are a careful research assistant.")
	body, _ := p.System(context.Background(), nil)
	fmt.Println(body)
}
Output:
You are a careful research assistant.

type PromptVars

type PromptVars map[string]any

PromptVars are template variables the runner threads through to a PromptSource on each Run. Aliased so call sites read as data-for- templates instead of a generic map. Accessors mirror tools.ToolParameters' shape but stay separate — the two represent different concerns (one is args from the LLM to a tool, the other is values for prompt rendering).

func (PromptVars) Bool

func (v PromptVars) Bool(key string) bool

Bool returns the value at key, or false when the key is absent or the value is not a bool.

func (PromptVars) Int

func (v PromptVars) Int(key string) int

Int returns the value at key, or 0 when the key is absent or the value is not an int (a float64 from decoded JSON does not match).

func (PromptVars) String

func (v PromptVars) String(key string) string

String returns the value at key, or "" when the key is absent or the value is not a string.

type PureFn

type PureFn func(name tools.ToolName) bool

PureFn reports whether a tool's outputs depend only on its args (and stable workspace state) within one Run. Returning true opts the tool into memoization; false (the default for unknown tools) passes through every call.

func PureTools

func PureTools(names ...tools.ToolName) PureFn

PureTools returns a PureFn that whitelists the named tools. Use this for the common "list these exact names" wiring:

runner.PureTools("read", "ls", "grep", "list_skills", "list_agents")

type RequireWork

type RequireWork struct {
	// Message overrides the correction surfaced to the model. Zero
	// value uses defaultRequireWorkMessage. Override for task-specific
	// phrasing (SWE-bench wants "produce the diff", a refactor wants
	// "apply the change").
	Message string

	// MaxCorrections caps gate holds per Run. Zero leaves the retry
	// bounded only by the runner's MaxIterations — almost never what
	// you want, since a model determined to do nothing would loop to
	// the cap. Set a small value (2 is typical).
	MaxCorrections int
}

RequireWork is the default CompletionGate: it refuses to let a Run complete on a no-tool-call turn until the run has made at least one successful mutating tool call. The zero value is usable; the empty struct exists so an option caller can install it by type.

func (RequireWork) Inspect

func (g RequireWork) Inspect(workDone bool, _ string) CompletionDecision

Inspect implements CompletionGate. Returns the configured correction when the run has done no mutating work; otherwise a zero decision so the runner completes normally.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner is the agent loop. Construct with New and call Run for each task. The runner's own state (client, registries, stores) is read-only after construction; per-task state is local to Run, so concurrent Run calls do not corrupt each other. Concurrent calls with the same explicit non-empty TaskSpec.ID are rejected; after the owning Run returns, the ID may represent a later generation.

Concurrent Run calls do, however, share the installed plumbing — EventSink, Steerer, Truncator, PromptSource, ToolOutputSink. Each interface documents its own concurrency expectations; for a Steerer in particular, sharing one queue across concurrent Runs splits inbound messages arbitrarily and is rarely what a caller wants.

func New

func New(
	client Client,
	opts ...options.Option[Runner],
) *Runner

New constructs a Runner. The only required argument is the LLM client (a streaming completion source — wrap an llm.Provider with ClientFromProvider when adapting). Everything else, including the tool source, is optional and supplied via options.

The tool source defaults to an empty registry, so a Runner with no WithTools is a valid tool-less agent rather than a deferred nil-panic in the loop. Supply the live tool list with WithTools.

A Runner defaults to NopSink; install explicit observation with WithSink.

Example (Headless)

Headless usage: a runner with a scripted client and no sink. The runner emits no events, runs to completion, and returns the model's final content.

package main

import (
	"context"
	"fmt"

	"github.com/zarldev/zarlmono/zkit/agent/runner"
	"github.com/zarldev/zarlmono/zkit/agent/runner/runnertest"
	"github.com/zarldev/zarlmono/zkit/ai/llm"
	"github.com/zarldev/zarlmono/zkit/ai/tools"
)

func main() {
	client := runnertest.NewClient([][]llm.CompletionChunk{
		{runnertest.ChunkText("hello"), runnertest.ChunkDone()},
	})
	reg := tools.NewRegistry()

	r := runner.New(client, runner.WithTools(reg))
	res := r.Run(context.Background(), runner.TaskSpec{Prompt: "hi"})

	fmt.Println(res.Reason, res.FinalContent)
}
Output:
completed hello

func (*Runner) Run

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

Run executes a task to completion or terminal condition. The loop is:

  1. Plant the current depth on ctx so spawn-agent can read it.
  2. Yield to ConversationLock if active.
  3. Drain the Steerer for any queued user-side messages.
  4. Build messages: [system?, ...spec.Context, user prompt + accumulated tool results].
  5. Stream Provider.Complete; publish chunks as LLM events.
  6. Extract structured tool calls; fall back to ParseFromText if none.
  7. Dispatch each call through the tool registry. Append results to the message history.
  8. Terminate when the model emits no more tool calls, when ctx is cancelled, on max iterations, or on a provider error.

The runner is safe to reuse across concurrent Runs — internal state (provider, registries) is read-only during a run; per-task state (message history, accumulated state) is local to this method.

Example

ExampleRunner_Run drives one task through the full loop with a scripted client: the model calls a tool on its first turn, reads the result, and completes on its second.

package main

import (
	"context"
	"fmt"

	"github.com/zarldev/zarlmono/zkit/agent/runner"
	"github.com/zarldev/zarlmono/zkit/agent/runner/runnertest"
	"github.com/zarldev/zarlmono/zkit/ai/llm"
	"github.com/zarldev/zarlmono/zkit/ai/tools"
)

func main() {
	// Turn 1: the model calls the weather tool. Turn 2: it answers.
	client := runnertest.NewClient([][]llm.CompletionChunk{
		{runnertest.ChunkToolCall("c1", "weather", `{"city":"Oslo"}`), runnertest.ChunkDone()},
		{runnertest.ChunkText("It is sunny in Oslo."), runnertest.ChunkDone()},
	})
	reg := tools.NewRegistry(runnertest.Tool{
		Name:        "weather",
		Description: "Report the weather for a city.",
		Result:      "sunny, 21C",
	})

	r := runner.New(client,
		runner.WithTools(reg),
		runner.WithMaxIterations(4),
		runner.WithSink(runner.NopSink{}), // silence the default stderr progress sink
	)
	res := r.Run(context.Background(), runner.TaskSpec{Prompt: "What's the weather in Oslo?"})

	fmt.Println("reason:", res.Reason)
	fmt.Println("iterations:", res.Iterations)
	fmt.Println("answer:", res.FinalContent)
}
Output:
reason: completed
iterations: 2
answer: It is sunny in Oslo.

type SpillingTruncator

type SpillingTruncator struct {
	MaxBytes int
	MaxLines int
	Dir      string // base directory for the session subdir (empty = os.TempDir())
	Prefix   string // filename prefix for the spill (e.g. "zarlcode-")
	// contains filtered or unexported fields
}

SpillingTruncator trims AND writes the original to disk so a follow-up bash can grep/head it. The footer of the returned text points at the spill file. Spill failures are non-fatal — the trim still happens; the footer just omits the path.

Lifetime

Every truncator instance lazily creates a private subdirectory (under Dir, or os.TempDir() when Dir is empty) on the first call that actually spills. All this instance's spills land in that subdirectory; SpillingTruncator.Cleanup removes the directory in one shot.

Use the pointer form so the lazy-init state survives across calls — `runner.WithResultTruncator(&runner.SpillingTruncator{ Prefix: "zarlcode-"})`. Call .Cleanup() in the consumer's shutdown path so a long-running agent doesn't accumulate spill files indefinitely.

func (*SpillingTruncator) Cleanup

func (t *SpillingTruncator) Cleanup() error

Cleanup removes the per-instance spill directory and every file in it. Idempotent — a second call (or one before any spill happened) is a no-op. Best-effort: a non-nil return is informational. Call from the consumer's shutdown path; without it a long-running agent leaves spill files in os.TempDir() indefinitely.

Guards against removing a shared base directory: if ensureSessionDir fell back to t.Dir / os.TempDir() (MkdirTemp failed), Cleanup is a no-op rather than nuking a parent we don't own.

func (*SpillingTruncator) Truncate

func (t *SpillingTruncator) Truncate(s, toolName string) string

Truncate keeps the tail of s, capped at MaxBytes/MaxLines (50KB / 2000 lines when zero). Before trimming it writes the full original to a file in the lazily created session directory and points the footer at it; a spill failure is non-fatal — the trim still happens, the footer just omits the path. Results under both caps pass through untouched. Pointer receiver so the lazy session-dir init via sync.Once survives across calls.

type SteerInjected

type SteerInjected struct {
	TaskID   taskscope.ID
	Depth    int
	Messages []llm.Message
}

SteerInjected fires when the runner picks up queued user messages from the Steerer between iterations.

type SteerSink

type SteerSink interface {
	OnSteerInjected(SteerInjected)
}

SteerSink observes when the runner picked up queued user messages from the Steerer between iterations. Useful for UIs that want to render the injected lines in the transcript.

type Steerer

type Steerer interface {
	Drain(ctx context.Context) iter.Seq[llm.Message]
}

Steerer drains queued user messages between iterations of Runner.Run. The runner calls Drain at the top of every iteration (after the ConversationLock yield, before message shaping). Drain MUST NOT block: it yields whatever messages are ready right now and returns. Yielding nothing is the normal idle case.

Returning iter.Seq lets the runner stop draining mid-stream if it ever wants to cap injected count; today it always consumes the whole iterator.

Implementations are typically owned by an interactive harness (TUI, REPL) that lets the user type while a turn is running and accumulates those lines into a queue. Headless runners (background tasks, scheduler-launched runs) leave the steerer unset.

Concurrency

Drain runs on the runner's goroutine. The harness producing messages (Append/enqueue from a UI event loop or notification callback) typically runs elsewhere. Implementations are responsible for synchronising the two sides — a sync.Mutex around the slice is the canonical shape; see zarlcode/tui's queueState.

One Steerer per Runner is the supported model. If the same Steerer is shared across concurrent Runs, both will Drain from the same queue and split its contents arbitrarily.

type SyncSink

type SyncSink struct {
	// contains filtered or unexported fields
}

SyncSink wraps an EventSink with a mutex so the wrapped sink is called from exactly one goroutine at a time. Use it when your sink is not already safe for concurrent calls (e.g. it appends to a slice or updates a map) — the runner fires events from multiple goroutines under WithToolConcurrency and across concurrent Runs, so an unsynchronised sink races. See EventSink's concurrency contract.

The wrapped sink must be non-nil. NewSyncSink panics if sink is nil.

func NewSyncSink

func NewSyncSink(sink EventSink) *SyncSink

NewSyncSink wraps sink so every event method serialises behind one mutex. sink must be non-nil — a nil sink is a programming error, not a no-op, so NewSyncSink panics rather than deferring the nil dereference to the first event.

func (*SyncSink) OnCompactionApplied

func (s *SyncSink) OnCompactionApplied(e CompactionApplied)

OnCompactionApplied forwards to the wrapped sink under the mutex.

func (*SyncSink) OnContent

func (s *SyncSink) OnContent(e Content)

OnContent forwards to the wrapped sink under the mutex.

func (*SyncSink) OnConversationEnded

func (s *SyncSink) OnConversationEnded(e ConversationEnded)

OnConversationEnded forwards to the wrapped sink under the mutex.

func (*SyncSink) OnConversationStarted

func (s *SyncSink) OnConversationStarted(e ConversationStarted)

OnConversationStarted forwards to the wrapped sink under the mutex.

func (*SyncSink) OnDiagnostic added in v0.11.0

func (s *SyncSink) OnDiagnostic(e Diagnostic)

OnDiagnostic forwards to the wrapped sink under the mutex.

func (*SyncSink) OnIterationCompleted

func (s *SyncSink) OnIterationCompleted(e IterationCompleted)

OnIterationCompleted forwards to the wrapped sink under the mutex.

func (*SyncSink) OnSteerInjected

func (s *SyncSink) OnSteerInjected(e SteerInjected)

OnSteerInjected forwards to the wrapped sink under the mutex.

func (*SyncSink) OnThinking

func (s *SyncSink) OnThinking(e Thinking)

OnThinking forwards to the wrapped sink under the mutex.

func (*SyncSink) OnToolCompleted

func (s *SyncSink) OnToolCompleted(e ToolCompleted)

OnToolCompleted forwards to the wrapped sink under the mutex.

func (*SyncSink) OnToolFailed

func (s *SyncSink) OnToolFailed(e ToolFailed)

OnToolFailed forwards to the wrapped sink under the mutex.

func (*SyncSink) OnToolStarted

func (s *SyncSink) OnToolStarted(e ToolStarted)

OnToolStarted forwards to the wrapped sink under the mutex.

type TaskCallLedger added in v0.1.3

type TaskCallLedger interface {
	RecordSuccessfulPureCall(ctx context.Context, tool tools.ToolName, args tools.ToolParameters)
	Calls(ctx context.Context) []ObservedCall
	ForgetTask(id taskscope.ID)
}

TaskCallLedger records successful pure tool calls per task so policy layers can ask what context the agent has already established in the current run.

type TaskResult

type TaskResult struct {
	ID         taskscope.ID
	Reason     TerminalReason
	Iterations int
	Duration   time.Duration
	// Cause refines cancelled and timeout outcomes without requiring callers to
	// inspect wrapped errors. It is empty for ordinary completion and faults.
	Cause TerminalCause

	// FinalContent is the LLM's last assistant message (if any).
	FinalContent string

	// Messages is the full conversation history at the moment the run
	// terminated, excluding the (re-built-each-turn) system prompt.
	// REPL-style callers feed this back as TaskSpec.Context on the
	// next turn so the agent sees its own prior tool calls and the
	// model's prior answers.
	Messages []llm.Message

	// SystemPrompt is the system prompt this run used, empty when the
	// runner had no PromptSource (or it rendered empty). Messages omits
	// the system message so REPL callers don't double it; SystemPrompt
	// carries it separately so the returned transcript is self-contained
	// for post-hoc debugging without external state.
	SystemPrompt string

	// LastUsage is the token-usage snapshot from the most recent LLM
	// completion this run made (provider streams report it in the
	// final chunk). REPL-style callers use this to decide when to
	// proactively compact — i.e. before the next request would push
	// the context past its window. Nil if the run never completed an
	// LLM call (e.g. early-error paths).
	LastUsage *llm.Usage

	// TotalUsage is the sum of every iteration's reported usage —
	// the run's full token spend, not just the final iteration's
	// snapshot. Multi-iteration runs (any task with tool calls)
	// accumulate one Usage per LLM completion; LastUsage carries
	// only the last, which under-reports total cost. Callers
	// tracking session-wide spend (or sub-agent spend, via
	// spawn-agent's child Run) should prefer TotalUsage over
	// LastUsage. Nil when the run never completed an LLM call.
	TotalUsage *llm.Usage
	// ToolSurface is the exact model-visible tool set from the final request.
	ToolSurface ToolSurface

	// Err is non-nil when Reason is TerminalError or TerminalCancelled. Nil otherwise.
	Err error
}

TaskResult is the output of Runner.Run.

type TaskSpec

type TaskSpec struct {
	// ID is the task's identifier. Used for event routing.
	// Empty values are auto-generated by Run.
	ID taskscope.ID

	// Prompt is the user's request — what the task should accomplish.
	Prompt string

	// Context is pre-loaded message history (e.g. recalled memories,
	// prior turn results) that the runner prepends to the user prompt.
	// Empty is fine.
	Context []llm.Message

	// Attachments carries multimodal content for the initial user turn, such as
	// image parts attached by a UI. Prompt remains the readable text; when
	// Attachments is non-empty the runner sends Prompt as a TextPart followed by
	// these parts.
	Attachments []llm.ContentPart

	// MaxIterations caps the loop. Zero means "use the runner's
	// configured default" (set via WithMaxIterations). Negative is
	// invalid (Run errors).
	MaxIterations int

	// Thinking enables the model's reasoning mode for this task
	// (where supported). Per-task instead of per-runner so a single
	// runner can serve thinking-on and thinking-off tasks side by side.
	Thinking bool

	// Depth is the spawn-agent recursion level — 0 for top-level
	// tasks, 1 for a child spawned by a root task, and so on. Only
	// spawn-agent should set this; callers outside the runner leave
	// it zero. Surfaces in events so subscribers can render sub-agent
	// indentation.
	Depth int

	// ParentToolCallID is the ID of the tool call that initiated this
	// task — set by the spawn-agent tool when launching a sub-agent
	// so the consumer's UI can attribute the child's events back to
	// the specific parent tool call that produced it. Empty for
	// top-level tasks and for any task whose initiator is not a tool
	// dispatch. Surfaces on [ConversationStarted].
	//
	// Without this field, parallel sub-agent dispatches are
	// indistinguishable at the event boundary: their ToolStarted
	// events fire at the parent's depth carrying the parent tool
	// call ID, but the child Run that follows publishes events at
	// the new depth with a fresh task ID and no link back. The
	// model's stack-based heuristic ("bind to the most recently
	// dispatched agent_spawn") breaks under parallel fan-out
	// because both children publish their ConversationStarted in
	// arbitrary order; this field gives the binding directly.
	ParentToolCallID string

	// AgentName is set by agent_spawn when a named sub-agent profile was
	// resolved for this task. Empty means the default/parent runner.
	AgentName string

	// PromptVars are template variables the runner threads through to
	// the installed PromptSource on each Run. Sources are free to
	// ignore them.
	PromptVars PromptVars
}

TaskSpec is the input to Runner.Run — everything needed to execute one agent task to completion (or until a terminal condition).

type TerminalCause added in v0.11.0

type TerminalCause string

TerminalCause identifies the concrete lifecycle boundary behind a terminal cancellation or timeout. The zero value means no classified boundary.

const (
	// TerminalCauseCaller means the caller's context ended.
	TerminalCauseCaller TerminalCause = "caller"
	// TerminalCauseIterationTimeout means the per-iteration budget ended.
	TerminalCauseIterationTimeout TerminalCause = "iteration_timeout"
	// TerminalCauseStreamIdle means the completion stream stopped producing chunks.
	TerminalCauseStreamIdle TerminalCause = "stream_idle"
)

type TerminalReason

type TerminalReason string

TerminalReason describes why a task ended.

const (
	// TerminalCompleted: the model emitted no further tool calls,
	// producing a final assistant message.
	TerminalCompleted TerminalReason = "completed"
	// TerminalMaxIterations: the loop exited because it hit the
	// iteration cap without the model settling on a final answer.
	TerminalMaxIterations TerminalReason = "max_iterations"
	// TerminalError: the loop exited because of an unrecoverable error.
	TerminalError TerminalReason = "error"
	// TerminalCancelled: the loop exited because ctx was cancelled.
	TerminalCancelled TerminalReason = "cancelled"
)

type Thinking

type Thinking struct {
	TaskID taskscope.ID
	Depth  int
	Delta  string
}

Thinking is a streamed reasoning delta — extended-thinking / chain-of-thought tokens, carried separately from visible Content so a UI can render them in a dedicated surface. Providers that inline reasoning as <think> tags route it through Content instead.

type ThinkingSink

type ThinkingSink interface {
	OnThinking(Thinking)
}

ThinkingSink observes streamed reasoning deltas (extended thinking / chain-of-thought), kept separate from visible content so a UI can render them in a dedicated reasoning surface. One OnThinking per reasoning-bearing chunk; subscribers accumulate themselves.

type ToolCompleted

type ToolCompleted struct {
	TaskID          taskscope.ID
	Depth           int
	ToolID          string
	ToolName        string
	Result          any
	FormattedResult string
	Effects         []tools.Effect
	Duration        time.Duration
	ParentToolID    string
	Sequence        int
}

ToolCompleted fires when a tool call returns successfully.

type ToolFailed

type ToolFailed struct {
	TaskID   taskscope.ID
	Depth    int
	ToolID   string
	ToolName string
	// Error is the user-facing failure message — safe to surface in a UI.
	Error string
	// Err is the underlying typed error (the result's *tools.Error, a
	// context error, etc.), carrying Op / Reason / Wrapped for sinks that
	// want errors.As/Is introspection or structured logging. NOT forwarded
	// to the UI — the flat Error string is what's user-facing — so internal
	// error detail doesn't leak into the transcript. Nil on legacy paths.
	Err error
	// Kind classifies the failure (validation / not_found / transient /
	// fatal / …) from the tool result's typed Err.Kind, so consumers render
	// or react to the class rather than substring-matching Error.
	Kind tools.Kind
	// Abandoned is true when the failure is the runner giving up on a tool
	// that blew its per-tool time budget while still in flight: the runner
	// stopped waiting and reported a timeout, but the tool's goroutine may
	// still be running and mutating state. Distinguishes "the tool failed"
	// and "the tool timed out and stopped" from "the tool timed out and
	// was abandoned with side effects possibly still in flight" — the one
	// a consumer may want to surface or alert on.
	Abandoned    bool
	Effects      []tools.Effect
	Duration     time.Duration
	ParentToolID string
	Sequence     int
}

ToolFailed fires when a tool call errors or reports failure.

type ToolOutput added in v0.11.1

type ToolOutput struct {
	ToolCallID string
	ToolName   string
	Args       string // raw JSON arguments string
	Output     string // full, untruncated tool result
}

ToolOutput is one captured tool result. The runner emits it before the truncator trims the model-facing text, so consumers can persist the full output for a tool-history surface.

type ToolOutputSink added in v0.11.1

type ToolOutputSink interface {
	Record(ctx context.Context, out ToolOutput)
}

ToolOutputSink receives full tool results before truncation. Implementations run synchronously on the runner goroutine and should be fast (a single INSERT or channel send), never blocking network calls. Nil sink disables capture.

type ToolProgressSink

type ToolProgressSink struct {
	NopSink
	W io.Writer
}

ToolProgressSink writes one terse line per tool event to w. It embeds NopSink so it ignores content, conversation, steer, and compaction events — only tool start/complete/fail are surfaced.

runner.StderrSink                     // pre-built default
runner.ToolProgressSink{W: buf}       // custom writer

Embed ToolProgressSink in a custom sink to get tool progress for free while overriding other event methods.

func (ToolProgressSink) OnToolCompleted

func (s ToolProgressSink) OnToolCompleted(e ToolCompleted)

OnToolCompleted writes "✓ <tool name>" to W.

func (ToolProgressSink) OnToolFailed

func (s ToolProgressSink) OnToolFailed(e ToolFailed)

OnToolFailed writes "✗ <tool name>: <error>" to W.

func (ToolProgressSink) OnToolStarted

func (s ToolProgressSink) OnToolStarted(e ToolStarted)

OnToolStarted writes "→ <tool name>" to W.

type ToolRegistry

type ToolRegistry interface {
	tools.Source
	Register(tools.Tool)
	Unregister(tools.ToolName)
}

ToolRegistry is the producer-side contract: read, dispatch, and mutate. Anything that wants to add or drop tools at runtime takes this; the runner does not.

type ToolSink

type ToolSink interface {
	OnToolStarted(ToolStarted)
	OnToolCompleted(ToolCompleted)
	OnToolFailed(ToolFailed)
}

ToolSink observes tool-call lifecycle. Each call dispatched by the runner produces exactly one OnToolStarted and exactly one of OnToolCompleted or OnToolFailed.

type ToolSource

type ToolSource = tools.Source

ToolSource is what the runner takes — read + dispatch, nothing else. Producers (the dynamic loader, the MCP bridge, the agent's own `register` tool) implement the wider ToolRegistry below; the runner only depends on this narrower view.

type ToolStarted

type ToolStarted struct {
	TaskID       taskscope.ID
	Depth        int
	ToolID       string
	ToolName     string
	Parameters   map[string]any
	ParentToolID string
	Sequence     int
}

ToolStarted fires when the runner dispatches a tool call.

type ToolSurface added in v0.11.0

type ToolSurface struct {
	Count       int
	JSONBytes   int
	Fingerprint string
	Changed     bool
}

ToolSurface is request accounting for the exact model-visible tool set.

type Truncator

type Truncator interface {
	Truncate(text, toolName string) string
}

Truncator caps the size of a tool-result string before it joins the message history. The toolName lets a Truncator tag any out-of-band artefacts (like a spill file's name) so a human can trace which call produced what; an in-memory implementation ignores it.

Concurrency

Truncate runs from arbitrary goroutines under WithToolConcurrency — the dispatch goroutine that ran the tool calls Truncate before appending to the message history. Implementations MUST be safe for concurrent calls. The shipped Default and SpillingTruncator are; the latter relies on os.CreateTemp's atomicity for unique spill paths.

type TurnQuality

type TurnQuality interface {
	Inspect(content string, toolCalls []llm.ToolCall) TurnQualityDecision
}

TurnQuality inspects an assistant turn (the finalised content + the structured tool calls extracted from the stream) and decides whether the turn is degenerate enough that the loop should inject a synthetic follow-up message instead of treating "no tool calls" as a clean terminal state.

The default detector (EmptyResponseDetector) catches the small- model failure mode where thinking fills max_tokens and leaves no real reply — without this, the runner exits with a successful but content-less TaskResult and the task quietly stalls. Consumers with richer quality signals can swap in their own implementation via WithTurnQuality.

Inspect returns a decision with a non-empty Correction when the runner should NOT exit on this turn; the runner appends the correction as a user message and continues the loop. Returning a zero decision lets the loop proceed normally (dispatch tool calls if any, exit if none). A decision may also request small next- iteration policy changes, such as disabling thinking for the retry.

Inspect runs only when the assistant turn produced zero structured tool calls — the dispatch path already covers turns with tools. The hook receives the post-thinking-stripped content (i.e. what the user would see) and the tool-call slice for context, even though a non-empty slice short-circuits the check upstream.

Implementations must be safe for concurrent use — a Runner is reusable across concurrent Runs.

type TurnQualityDecision

type TurnQualityDecision struct {
	Correction      string
	DisableThinking bool
	MaxCorrections  int
}

TurnQualityDecision is the runner-side action requested by a TurnQuality hook. Correction is the user-side message to inject. If DisableThinking is true, the next and subsequent iterations in this Run use spec.Thinking=false; that mirrors the recovery needed for models that consumed their whole budget in the reasoning channel. MaxCorrections caps how many times this quality hook may inject a correction during one Run; zero means unlimited and preserves the original max-iterations-bounded behaviour.

Directories

Path Synopsis
Package runnertest provides shared test fakes for code that uses zkit/agent/runner: a scriptable Client, a recording EventSink, a minimal Tool stub, and chunk constructors.
Package runnertest provides shared test fakes for code that uses zkit/agent/runner: a scriptable Client, a recording EventSink, a minimal Tool stub, and chunk constructors.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL