The agent loop
One iteration calls the model once, runs whatever tools it asked for, and feeds the results back.
Where it lives
internal/agent splits in two. agent.go is setup and teardown: load tools, resolve the provider, build stores, resolve the session, install hooks, and own the panic barrier. runner.go is the loop itself. Supporting files: toolset.go, hooks.go, events.go, approvals.go, pii.go, mcplive.go.
One iteration
- Poll for suspend Only at the loop boundary, and before the iteration index is consumed, so a suspend does not burn one.
- Take the tool set once A snapshot serves the model call and its whole tool batch. A tool removed mid-batch cannot strand a call the model already made.
- Call the model Under the per-call timeout the provider owns.
- Journal the assistant turn before running any tool A crash mid-batch resumes without paying for the model call a second time.
- Execute each tool call Validate arguments, apply the confirm gate, trace, dispatch, journal the result.
- Feed the results back All results become one user message and the loop iterates. If anything deferred, nothing is appended and the run suspends.
What one tool call passes through
executeTool has eight exits and one deferred span covering all of them. In order:
- Registry lookup An unknown name is counted under its own kind and answered with an error result the model can adapt to, and the run warns. The call is never dispatched.
- Describe and check gating on the original call Done before any hook, so
PreToolUsesees what the model actually asked for. - PreToolUse May deny, or rewrite the tool and its arguments. A rewrite targeting an unregistered tool or carrying invalid JSON aborts the run rather than dispatching a malformed call.
- Argument validation Runs before the gate, so an operator is never asked to approve a structurally invalid call. A fisk command drops a missing required flag silently, so without this the failure would only surface as the command's own exit.
- Confirm gate Fires if either the original or the effective tool is gated.
- Trace and dependencies The tool receives a prompter or a work directory only if it said it needed one.
- Takeover check The last check before an irreversible effect. A run taken over on a shared store stops here rather than at its next append.
- Execute, then PostToolUse A timeout message is substituted before the hook runs, so hook, journal, event sink and model all see the same output.
A non-zero command exit is deliberately not an error outcome. It is an answer the model should reason about, and counting it would make the error rate meaningless.
Load-bearing decision
The gate fires on the union of the original and effective calls, so a PreToolUse hook cannot strip a gate by redirecting a gated call to an ungated tool.
Budgets and timeouts
| Limit | Scope | Zero means |
|---|---|---|
llm.budget.max_output_tokens | One model reply. Defaults to 8192, raised to 16384 when thinking is on | The built-in default |
llm.budget.max_tokens | Cumulative across the conversation: input, output, cache reads and cache writes | Unbounded |
llm.budget.max_iterations | An absolute position, grown by the configured amount on each accepted follow-up | Refused |
llm.budget.call_timeout | One provider call, enforced inside the provider | Refused |
harness.tool_timeout | One tool call, as a context deadline | No limit |
The cumulative check runs before a tool batch and at the head of a follow-up turn, but deliberately after a completed answer is returned, because those tokens are already spent.
The tool timeout has two exemptions: an operator who asked for none, and a tool marked operator-paced, where the deadline would cancel the operator’s own question rather than a runaway command. A command tool is really killed with its process group; an in-process tool stops only when its handler observes the context.
Approval, deferral and suspend
human_in_the_loop adds the ask_human_* tools to the run. The confirm gate is independent of it: it stands in front of a tagged command and is default-deny, so with no prompter it refuses before asking anything.
The gate checks for a prompter, then for an already-expired context, and only then consults standing and one-shot grants. A grant restored from a journal therefore cannot run a gated command with nobody present.
A grant is honored from the moment it is given but staged, and written only after the triggering call is answered or deferred. A crash in between loses the grant, and the resume asks again for a command it is about to re-run. There is no standing denial, so a run that ended before the operator answered cannot persist a decision they never made.
A deferral is a tool saying it will answer later. The call is journaled as deferred, grants are flushed, and the run ends suspended. A deferred call is never dispatched again; its turn finishes only when an answer is supplied through the checkpoint.
Load-bearing decision
A turn is never committed while a tool_use has no result. If anything in a batch defers, nothing is appended to the conversation and the run suspends with the batch intact.
Hooks
In loop order. All run on the single run goroutine.
| Hook | Fires | Can |
|---|---|---|
RunStart | Once, before a session is created or opened | Abort |
UserPromptSubmit | On each prompt entering the conversation | Deny, rewrite |
PreModelCall | Before each model call, above the provider | Abort |
PostModelCall | After each reply, including a truncated one | Abort, but not durably: the turn is already journaled |
PreToolUse | Before validation, gate, trace and execution | Deny, rewrite the tool and its arguments |
PostToolUse | After execution, before the trace and journal | Replace the output |
TurnEnd | At an interactive continuation boundary | Abort |
RunEnd | At teardown, after stores close, including on a crash | Nothing |
PreToolUse is the only reliable place to block a tool.
PII scanning
The guard wraps UserPromptSubmit and PostToolUse, and the run installs it itself so every path into the loop is covered. It composes with the caller’s hooks rather than replacing them: the caller’s hook runs first and the scan reads whatever it left behind, including its rewrite.
Under redact a hit rewrites the text. Under reject a prompt is denied and a tool output is replaced with a fixed withholding message. A scan that fails is treated as a hit, and the cause reaches the operator through an advisory and the log rather than the model. The operator warning is raised once per run, because a chat redacting on forty tool calls would bury its own answer.
A mode other than off whose scanner will not build fails the run, because carrying on would send unscanned text to the model with no sign that scanning had stopped.
Setup and teardown
Tool assembly runs in a fixed order into one flat namespace, and every collision aborts the run rather than shadowing, because shadowing a confirm-gated command would strip its gate.
The panic barrier is registered after the telemetry spans so it unwinds first. It captures the stack before running any caller code, fires RunEnd exactly once, delivers the stack to the event sink inside its own recover, and substitutes a PanicError for the returned error. The stack stays off that error because the error may cross to a remote peer. It covers this goroutine only, not fatal runtime errors.
MCP advisories arrive on another goroutine and land in a mutex-guarded queue the loop drains where it takes tools for a model call, so an advisory arrives with the call that carries the set it is about. The tool set itself is an atomic pointer to a whole immutable set, so a reader never sees one change under it.
Next
Continue to Tools and introspection for how the set the loop dispatches against is built, or Durable state for what the journal holds and how a resume reads it.