Agent execution loop with prompt expansion, turn-scoped tool exposure, caching, and output routing.
runner owns the runtime path from a resolved model.Agent to a completed
model.RunRecord. It builds the session, applies prompt variables, resolves
skill and toolset scope, executes multi-round tool calls, records progress, and
fans the final content out to file, queue, HTTP, and notify destinations. The
CLI wires Runner once and reuses it for serve, run, and test-oriented
paths.
| Symbol | Signature | Description |
|---|---|---|
DefaultToolRounds |
const DefaultToolRounds = 5 |
Fallback tool-round cap when neither config nor agent overrides it. |
Runner |
type Runner struct { ... } |
Runtime executor. Fields: Client, Registry, Log, MaxToolRounds, Cache, QueueMgr, Notifiers, MCPRegistry, HideBuffer, HideStoreFn, ProgressFn, DebugContextFn, ForceTextAfterHide, NoToolsForFirstTurn, NoToolsForLastTurn, Vars. |
ProgressEvent |
type ProgressEvent struct { ... } |
Event emitted during prompt and tool activity. Consumed by the pretty-mode CLI and the devtools bus. |
ContextSnapshot |
type ContextSnapshot struct { ... } |
Point-in-time view of the exact input sent to one LLM completion call (AgentName, Turn, Round, Messages, etc.). Delivered to Runner.DebugContextFn immediately before each client.Complete call. |
(*Runner).Run |
func (r *Runner) Run(ctx context.Context, a model.Agent, budget model.TokenBudget) (model.RunRecord, error) |
Execute one agent run, including tools, cache, hooks, and output routes. |
TurnRequireFor |
func TurnRequireFor(a model.Agent, i int) []string |
Tools declared by turn i's require_tool:, at least one of which must be called before that turn may end on text. Exported so leather validate checks what the runner enforces. |
BuildRunData |
func BuildRunData(a model.Agent) map[string]any |
Build standard prompt-template variables: agent_name, schedule, now, tags. Merged with Runner.Vars per turn. |
ExpandPromptPayload |
func ExpandPromptPayload(a model.Agent, payload map[string]any) (model.Agent, error) |
Apply text/template substitution to prompt text from queue payloads. |
The runner is the integration point for buffered hides:
HideBuffer *hide.HideBuffer— when non-nil, oversized tool outputs are intercepted and paged throughhide_next/hide_jump/hide_searchtools instead of being delivered in full.ForceTextAfterHide bool— after a hide-navigation tool fires within a round, strip all tools from the next call so the model is forced to emit text rather than chain another paging call.NoToolsForFirstTurn bool— suppress all tools on user-turn 0; pairs withForceTextAfterHidefor "reflect on the first page" workflows.NoToolsForLastTurn bool— suppress all tools on the final user turn so the closing structured output is plain text.
Run starts by firing the agent's lifecycle hooks, building the base tool
scope from a.Skills and a.Toolsets, and appending prompt text from active
skills. When Runner.Vars is populated, both {{key}} and {{.key}}
placeholders are substituted into the system prompt and every user prompt
before the first LLM call.
Caching happens before model execution once prompt augmentation is resolved. If
there is a cache hit, the runner returns a synthetic success RunRecord
immediately.
Turn execution is scope-aware. A base tool scope comes from the agent-level
skill and toolset lists, but any turn that declares TurnSkills,
TurnToolsets, or TurnTools replaces that base scope for that turn only.
Tool calls are validated against the current scope, then executed through
tool.Executor, which can reach either HTTP or MCP tools.
Self-healing retry: if a completion is truncated (finish_reason: "length")
before producing any content or tool calls — typical of a reasoning model
whose <think> trace exhausts max_tokens before an answer exists — Run
retries once with a doubled max_tokens, capped by the model's remaining
context window for that call's prompt size. A warning is logged; no retry is
attempted if there's no room left to grow into, or if the completion already
produced content or tool calls.
A turn may name required tools via model.Agent.TurnRequireTools. The runner
resolves them against that turn's own scope before the first LLM call — a
requirement nothing in scope can satisfy fails immediately instead of burning
every round — and refuses a text response until one of them is dispatched,
failing the run if the rounds run out first. A dispatched call counts whether or
not it succeeds; an out-of-scope call never executes, so it never counts.
Output routing is intentionally non-fatal. file writes use 0600 permissions,
queue routes stage the response in the hide store and enqueue a
model.QueueItem referencing it, http routes send plain text with
configurable method and headers, and notify routes deliver notify.Message
payloads through named backends. One failed route does not prevent the others
from running.
queue routes need HideStoreFn to resolve a store. It is a function because
leather serve registers scheduled agents before it constructs the tannery,
while routing happens at run time. When it resolves to nil the route warns and
enqueues nothing: an item without a hide is one the consuming curing loads,
fails to find, and dead-letters on first touch
(#83).
| Package | Why |
|---|---|
internal/session |
Session management and the LLM client interface. |
internal/tool |
Tool lookup and HTTP/MCP execution. |
internal/mcp |
Registry of started MCP clients passed through to tool.Executor. |
internal/cache |
Pre-run cache lookup and post-run cache write. |
internal/queue |
Queue input expansion and queue output routes. |
internal/notify |
Notify output routing. |
internal/hide |
Buffered-hide store and pagination for oversized tool output. |
internal/model |
Agent, run, token, route, and queue types. |
internal/logging |
Structured runtime logging. |
flowchart LR
A[Agent + budget] --> VARS[prompt var expansion]
VARS --> CK{cache hit?}
CK -->|yes| RR[RunRecord]
CK -->|no| SCOPE[resolve base scope]
SCOPE --> TURN[per-turn scope override]
TURN --> LLM[session + Complete]
LLM --> TOOLS{tool calls?}
TOOLS -->|yes| EX[tool.Executor]
EX --> LLM
TOOLS -->|no| OUT[route output]
OUT --> RR
internal/runner/runner_test.go covers no-tool runs, LLM failures, multi-round
tool loops, unknown tool rejection, round-cap failures, turn skill/toolset/tool
scope replacement, prompt payload substitution, route-output behavior, and the
self-healing truncation retry (including the no-retry guard when content is
already present) using mock notifiers, temp files, and queue managers.