Skip to content

Agent run loop & budgets

The runner is provider-blind: complete → execute tools → append results → repeat. Budgets guarantee a run always ends with a useful message — never a silent death.

Behavior

  1. Wall clock is the real budget; turns are a backstop. Each agent defines maxMinutes (hard deadline) and maxTurns (runaway guard). update_status-only turns don't consume turns; an absolute iteration cap of maxTurns × 2 bounds the loop regardless.

  2. Wrap-up warning: once, as the deadline approaches (≤3 min or 25% left), the model is told how long remains and to consolidate rather than explore.

  3. Forced write-up: on budget exhaustion the model gets one final tool-less call to report findings so far + what a follow-up should do; the answer is prefixed ⚠️ Hit the <N>-minute/-turn budget…. An empty write-up still produces a user-facing message. The finale call is bounded (FINALE_TIMEOUT_MS, 3 min; RunOptions.finaleTimeoutMs for tests): a provider that hangs on the write-up yields the empty-write-up fallback message instead of a run that never ends — for every finale path (budget, dead sandbox, soft stop).

  4. Refusals surface as a clear user-facing message suggesting rephrase/model-switch; token-limit truncation is labeled, never silent.

  5. Current budgets: general 1 turn/5 min · review 30 turns/25 min · coding 60 turns/45 min. Changing them is a feature change — update this file and the registry together.

  6. Per-run system override: RunOptions.system replaces agent.system for a single run — every provider call in the run, including the forced write-up, uses it. The dispatcher can choose an effective prompt after executor resolution (resident-repo context, later milestones) without ever mutating the shared AgentDef (concurrent dispatches share it). No override → agent.system, unchanged.

  7. Fail fast on an unrecoverable sandbox: an exec-infrastructure failure (the sandbox unreachable, an HTTP/worker error, the sandbox's "Command execution failed" / exitCode-127 signal, a worktree unrecoverable after re-attach) is categorically distinct from a normal nonzero command exit — remote executors throw ExecInfraError for it, while a nonzero exit is returned as ordinary output. The runner watches sandbox health through the executor seam (ExecHealthTracker): after MAX_CONSECUTIVE_INFRA_FAILURES (default 2) infra failures with no successful op between them, it stops issuing commands into the dead sandbox and ends through the same guaranteed finale — a tool-less write-up led by an evidence-only diagnostic: "Sandbox exec transport failed N times in a row (last: <the last ExecInfraError message, verbatim>). Aborting instead of retrying into a dead sandbox." — the same text goes to the model in the finale instruction, which tells it to quote that error and not speculate about another cause. The diagnosis never asserts a cause (from the outside, a deploy replacing the resident isolate mid-run is indistinguishable from OOM/disk — a guessed cause misleads); a generic hint (memory/disk/transport) appears only when the last error carried no text. A single successful exec resets the counter, so a one-off blip never aborts, and a normal nonzero exit (output, not a throw) can never trip it. This turns minutes of toil-until-wall-clock into a fast, legible outcome. Capacity errors do not count: an ExecCapacityError (the sandbox fleet had no free instance within the executor's bounded wait — execution.md item 14) is neither counted nor a reset; the runner emits a typed fleet_busy note and returns the tool result as ⏳ Sandbox fleet busy — <message>. Retry the command in a minute or finish with what you have., and the run goes on (read as infra failures, two such errors would abort a review within seconds of its start).

  8. Operator stop — soft and hard: every run owns a RunControl (minted by RunRegistry.create(), passed as RunOptions.control); an operator drives it from /runs (see live-view.md item 10). Soft — the loop condition checks control.requested before every step, so no NEW step starts; the step already in flight completes (all of its tool results are appended, keeping the transcript valid), then the run ends through the same guaranteed finale as budget exhaustion with a stop-specific instruction, and the answer is labeled ⏹ Stopped early by an operator (soft stop) — findings so far:. Hardcontrol.hardSignal is an AbortSignal the runner threads into every provider.complete (CompletionRequest.signal) and into tools via ToolContext.signal (bash → Executor.exec(cmd, { signal })), AND every await in the loop is raced against it, so the wait ends the instant the signal fires whether or not the implementation underneath honors cancellation (Anthropic/OpenAI-compat providers and the Local/Resident/Cloudflare-sandbox executors do; E2B's command API cannot and degrades to race-only). A hard stop runs no finale — the answer is the one-line ⛔ Run aborted by an operator (hard stop)… — and the dispatcher releases the workspace with release("always") regardless of agent (resident /detach force; E2B kills the micro-VM), skips the memory reflection and the review post-step (there are no findings to distill or post). A soft stop already in its finale escalates: a hard request abandons the finale. Both notes reach the stream as typed run_notes (stop_requested from the registry, stopped from the runner, each carrying mode), the card's final icon is ⏹ (soft) / ⛔ (hard), and a stop can never throw out of the loop — the only throw path a hard stop uses is caught in runAgent and turned into the abort message. No control (CLI, tests) → the loop is byte-for-byte the loop without stops (no signal is handed to the provider).

  9. Side-effect-free tools in one turn run concurrently. A tool declares sideEffectFree: true when it only reads (read_file, diff_digest — which on a shallow clone deepens the clone's own history first, the object store and never the worktree —, web_fetch, web_search, list_skills, use_skill); everything that mutates the workspace (bash, write_file) or the run's own state (update_status, submit_verdict) does not. When a completion returns several tool_use blocks, the runner executes each maximal run of consecutive side-effect-free calls with Promise.allSettled and every other call alone, in the model's order — so five read_files cost one resident round trip, not five, while bash never overlaps anything. The model sees no difference: tool_result parts are appended in the model's order regardless of completion order, so messages is byte-identical to the serial loop; on the stream the batch's tool_call events are emitted before its tool_results (paired by callId). A hard stop mid-batch unwinds once — the sibling rejections are collected, never left unhandled.

  10. Prompt caching on the Anthropic adapter (buildAnthropicParams, pure): four cache_control: ephemeral breakpoints per request (the API's ceiling) — the static prefix (the last tool + the system prompt as one text block) and two rolling ones on the last block of the last message and of the message before it. The loop only appends, so turn N's conversation is a prefix of turn N+1's; the second rolling breakpoint sits where the previous turn's did, so the cache lookup lands even when one turn appended more blocks than the API's lookback window (a big read batch) — every turn after the first reads the transcript so far from cache instead of re-billing it in full; usage.cacheReadTokens on the turn event shows it. The request's own messages are never mutated (breakpoints are set on the mapped copy).

  11. Cache TTL per agent, thinking replayed, effort the model accepts. Builds on item 10. TTL: every breakpoint carries CompletionRequest.cacheTtl, set from AgentDef.cacheTtl and passed by the runner on every call including the finale — 5m by default (a read refreshes the entry free, strictly cheaper while requests start < 5 min apart), 1h for coding (2× write, the only window in which a step that runs past 5 minutes — coding model turns of 5–6 min happen — still reads what the previous call wrote). Review/research/general stay on 5m. A rolling breakpoint lands on the last block that can carry cache_control (text/image/document/tool_use/tool_result) — never on a thinking block, which the API rejects it on; a turn made only of thinking blocks gets none. Thinking replay: thinking / redacted_thinking blocks are kept in CompletionResult.content as opaque ContentParts and mapped back byte-for-byte in the assistant turn — the API verifies each signature and 400s on a modified/reordered block, and dropping them breaks the turn on Claude Fable 5; collectText skips them, nothing renders them, the OpenAI-compatible adapter drops them. Effort: levels are low | medium | high | xhigh | max (src/effort.ts); effortFor(model, effort) omits the parameter on Haiku/pre-4 models (400) and clamps xhigh/maxhigh on Opus/Sonnet ≤ 4.6 (400 there; dated bare-major ids like claude-sonnet-4-20250514 count as 4.x), so a model: override degrades instead of failing. Pinned per run (an effort change invalidates the messages cache). @anthropic-ai/sdk 0.39 → 0.122 for the typed cache_control.ttl, output_config.effort and thinking-block params. Measured on the review agent (Claude Fable 5, same PR, real API): uncached input 19,168 → 16 tokens per run, −60 % cost vs the same tokens uncached.

  12. The step report (run-history.md item 35). RunOptions.onStep, when given, is awaited BEFORE any of a step's tools run — after the assistant turn is appended, before the first tool.run — with a StepReport: the messages appended since the previous report (the previous step's results turn and this step's assistant turn; for the first report, everything after the seed messages), the index of the first of them, the calls about to be dispatched (callId, tool name), and turn/iteration/remainingMs. The seed plus every report is exactly the conversation the model has seen, without gaps — the invariant the run ledger's transcript rests on. A report that throws fails the step before any tool runs; the runner never swallows it (the hook decides). Absent → no report and a byte-identical loop.

  13. One tool result can never fill the context. Every tool caps its own output where it knows the shape (bash 120k characters, GitHub files 200k, web pages 40k with paging — web-tools.md item 7); behind all of them the runner hands the model at most MAX_TOOL_RESULT_CHARS (120,000 characters, the bash cap) of text per tool result — a string result or each text part — cut with a visible …[tool result truncated: N of M characters cut — ask for a narrower slice], never silently. Image and document parts ride through: they are bounded by their own byte caps and are not text. The run stream's tool_result summary is capped separately and earlier (prepareToolResult); this is the model-facing ceiling, so a tool that forgets its own cap — or a new one — costs one truncated result, not a prompt is too long 400 that ends the run.

  14. Re-entry from a transcript (run-history.md item 37). With RunOptions.resume, messages is a reclaimed run's transcript and the loop starts where it stopped: the deadline is now + remainingMs from the last step record (never the agent's whole budget again), turn and the loop index come from the plan, a resumed run note is emitted first, and the calls that were in flight at the kill are settled before the first model call — each re-run through the same tool dispatch as a live step (dispatchToolUses, shared with the loop) or answered with the plan's synthetic result — and their results appended as the user turn the model needs; a step whose record never landed is reported through onStep with no new turns before its calls run. Absent → a fresh run, byte-identical to before.

  15. An answer written alongside a bookkeeping call is the answer. Text that rides with a tool_use is narration (an assistant event before the tool rows it explains), never the answer — right for a real tool, wrong for update_status, which is bookkeeping: a model that answers and updates the card in one turn is done, and the forced extra turn has nothing left to say. So the text of a turn whose every tool_use is update_status is HELD, not narrated. The next completion decides: empty text-only → the held text is the answer (no assistant event; the record carries it once, as the answer); non-empty text-only → the held text is narrated, the new text answers; more tools, a refusal, or the loop ending in a finale → the held text is narrated first. A resumed run (run-history.md item 37) whose transcript ends on such a turn and its results holds that text again, so its first completion decides the same way. A demoted text is emitted when that next completion arrives, so on the timeline it follows the checklist rows it was held behind — one turn later than a real tool turn's narration, which still precedes its tool rows. Text alongside a real tool call is unchanged: narration, and an empty final turn still answers _(no response)_.

Validation criteria

CriterionProof
cacheTtl on every breakpoint incl. the finale call; rolling breakpoint skips thinking blocks; thinking blocks round-trip unchanged (Anthropic) and are dropped (OpenAI-compat); effort clamped/omitted per model; levels low…max accepted by directives/config (item 11)[unit] src/providers/anthropic.test.ts::buildAnthropicParams — cache TTL and breakpoint placement…::* (3), ::…thinking blocks are replayed unchanged…::* (2), ::effortFor…::* (4); src/providers/openaiCompat.test.ts::toOAIMessages — thinking parts; src/runner.test.ts::model-call hygiene…::* (3); directives/config/dispatcher effort-hint tests
Before/after on the same review request: uncached input collapses, cache_read_input_tokens grows turn over turn, lower $ per run (item 11)[agent] A/B harness against the real API (both arms review the same PR from a local workspace, submit_verdict stubbed) — compare the two arms' per-turn usage tables
Within-budget runs return the model's answer verbatim[unit] src/runner.test.ts::returns the model's answer…
Turn exhaustion → tool-less forced write-up labeled N-turn[unit] src/runner.test.ts::forces a write-up…turns run out
Deadline exhaustion → write-up labeled N-minute[unit] src/runner.test.ts::labels the write-up with the minute budget…
Item 15: the text of an update_status-only turn is the answer when the next turn is empty (not narrated too), narration when the model writes another answer, calls a real tool, refuses, or a budget finale writes up, and held again on a resume whose transcript ends on the bookkeeping turn; text beside a real tool call stays narration and an empty final turn stays _(no response)_[unit] src/runner.test.ts::an answer written alongside a bookkeeping call (docs/reference/specs/run-loop.md item 15)::*
Status-only turns don't consume the turn budget[unit] src/runner.test.ts::update_status-only turns…
Refusal and truncation surfaced legibly[unit] src/runner.test.ts::surfaces safety refusals as a user-facing message, ::marks truncated answers when the token limit was hit
No tool result reaches the model above MAX_TOOL_RESULT_CHARS: a string result and a text part are cut with a visible note naming how much was cut; an image part rides through untouched[unit] src/runner.test.ts::runAgent budgets::caps every tool result the model sees at MAX_TOOL_RESULT_CHARS, visibly…
Wrap-up warning fires once near the deadline[unit] src/runner.test.ts::emits the wrap-up warning… (runner takes an injectable now clock).
RunOptions.system override reaches every provider call; absent → agent.system[unit] src/runner.test.ts::a system override in RunOptions reaches the provider request, ::the system override also governs the forced write-up call, ::without an override the agent's own system prompt is used
K consecutive infra failures → fast abort via the finale with the diagnostic, not toil to the wall clock[unit] src/runner.test.ts::aborts via the finale after consecutive infra failures instead of toiling into a dead sandbox
The abort diagnostic quotes the actual last infra error (count + verbatim message) in the ⚠️ line, the sandbox_dead note, AND the finale instruction; it never asserts "OOM/disk"[unit] src/runner.test.ts::aborts via the finale after consecutive infra failures instead of toiling into a dead sandbox (asserts the error text is present and `/OOM
With no error text captured, the diagnostic falls back to a generic hint and still says how many failures[unit] src/runner.test.ts::falls back to a generic hint only when the last infra error carries no text
A normal nonzero command exit never aborts (agent keeps handling it)[unit] src/runner.test.ts::does NOT abort on ordinary nonzero command exits…
A single infra failure followed by a success does not abort (counter resets)[unit] src/runner.test.ts::does NOT abort when a single infra failure is followed by a success…
Two consecutive ExecCapacityErrors (a full fleet) do NOT abort: the run continues and finishes with the model's own answer, no result is marked infra, a fleet_busy note is emitted per occurrence, and the model sees the ⏳ text[unit] src/runner.test.ts::fleet-busy capacity errors do not trip fail-fast::two consecutive ExecCapacityErrors leave the run going: it finishes normally with the model's own answer, ::emits a typed fleet_busy note per occurrence and never sandbox_dead, ::the model sees the ⏳ text with the error and the two ways forward (retry in a minute, or finish)
Infra-vs-exit classification + consecutive-failure counting at the executor seam[unit] src/execution/executor.test.ts::ExecHealthTracker (counts consecutive ExecInfraError, resets on success, non-infra throw untouched, nonzero-exit output resets)
Soft stop: the in-flight step completes, no NEW step starts, the run ends through the tool-less finale labeled as an early stop, and a typed stopped(soft) note is emitted[unit] src/runner.test.ts::run control: soft / hard stop …::soft stop: takes no new step after the request…, ::soft stop requested before the first step… (red-verified before implementation)
Hard stop mid-tool: the tool wait ends immediately (the tool's own completion is never awaited), no finale call, ⛔ … aborted answer, stopped(hard) note[unit] src/runner.test.ts::…::hard stop mid-tool: aborts the in-flight tool immediately, no finale
Hard stop mid-inference: the provider call is handed the hard AbortSignal (aborted) and its result is discarded[unit] src/runner.test.ts::…::hard stop mid-inference…
A hard request during a soft finale abandons the finale[unit] src/runner.test.ts::…::hard stop escalates a soft stop already in its finale…
A stop never throws out of the loop (a tool rejecting on abort is still an orderly outcome)[unit] src/runner.test.ts::…::a hard stop never throws out of the loop…
No control → loop unchanged, no signal reaches the provider[unit] src/runner.test.ts::…::without a control the loop is unchanged…
A hard stop already in effect when a call starts leaves NO unhandled rejection (the abandoned provider/tool promise is always handled)[unit] src/runner.test.ts::…::a hard stop that is ALREADY in effect when a call starts leaves no unhandled rejection (red-verified: removing the fast-path catch surfaces the rejection)
The finale is bounded: a provider hanging on the write-up yields the fallback message[unit] src/runner.test.ts::…::the finale is bounded…
Hard stop frees resources: the dispatcher releases with always even for a coding run; card ends ⛔; index shows stopped(hard); the answer is the abort line, never the abandoned provider output[unit] src/core/dispatcher.test.ts::a hard stop from /runs releases the executor with 'always' and reports the abort
Soft stop keeps the normal release policy (if-clean for coding), posts the finale, card ends ⏹[unit] src/core/dispatcher.test.ts::a soft stop keeps the normal release policy…
A hard-stopped review posts nothing to the PR[unit] src/core/dispatcher.test.ts::review post-step …::a hard-stopped review posts nothing to the PR (red-verified: guard removed → the abort line is posted)
A hard-stopped run never reflects into memory, even when the reflection gate would qualify it[unit] src/core/dispatcher.test.ts::cross-session memory WRITE path …::a hard-stopped run does NOT reflect, even when the gate would qualify it (red-verified: guard removed → the abort line is distilled)
The hard signal reaches the executor seam and kills a local child process promptly (legible exit … text, no throw)[unit] src/execution/executor.test.ts::ExecHealthTracker::forwards the exec abort signal…, ::LocalExecutor exec abort::kills a running command…
Live: Stop on a real run wraps up with a ⏹ … findings so far reply; Kill on a real run replies ⛔ … aborted within seconds and the card ends ⛔[agent] Button steps: live-view.md item 10. Soft — start a review of an open PR on a resident repo, press Stop on /runs after a few events → card ⏹ review · <elapsed>, thread reply ⏹ Stopped early by an operator (soft stop) — findings so far: …, and the partial findings post to the PR as not-approving. Hard — start a coding run that executes sleep 300, press Kill → 200 {state:"stopping"}, card ⛔ coding · <elapsed>, reply ⛔ Run aborted by an operator (hard stop)….
Hard stop releases the resident pool user even while the killed command is still running in the container: Kill a coding run mid-sleep 300 from /runs → within 10 s /residents shows the thread user:"", evicted:true; bot log [release] … released (not kept (busy: …))[agent] Steps as in the criterion; also expect inFlight 0 on /residents, card ⛔ coding · resident · <ref@sha> · <elapsed>, reply ⛔ Run aborted by an operator (hard stop)…. Failure signature: the thread keeps its pool user and the bot log says detachThread … busy: 1 operation(s) in flight — kept because the abandoned sleep 300 is still executing — the user is then held until the hourly sweep.
Several read_file calls in one turn run concurrently (all in flight before any finishes); results are appended in the model's order; every tool_call precedes the batch's tool_results[unit] src/runner.test.ts::runAgent tool concurrency::runs several read_file calls from one turn concurrently…
A mutating tool (bash) never overlaps a read and keeps its position in the results[unit] src/runner.test.ts::runAgent tool concurrency::keeps a mutating tool serial…
A hard stop during a concurrent batch unwinds once with no unhandled rejection[unit] src/runner.test.ts::runAgent tool concurrency::a hard stop during a concurrent batch unwinds once…
Anthropic requests carry cache_control on the system block, the last tool, and the last block of the last message (a tool_result/hoisted document included); earlier messages carry none; the request's own messages are not mutated[unit] src/providers/anthropic.test.ts::buildAnthropicParams (prompt-cache layout)::*
Live: the second and later model.turn spans of a run show cached tokens on the run page (Thought for … · N in · M out · K cached)[agent] Run agent:review on any PR; open the run page; from the second turn on, the turn row's facts include a non-zero cached count.
The answer is sent to the thread BEFORE the workspace release round trip[unit] src/core/dispatcher.test.ts::the answer reaches the thread BEFORE the workspace release round trip…
The step report fires before the step's tools with the turns since the last report, their first index and the calls in flight; seed + reports = the model's conversation; a throwing report fails the step before any tool runs[unit] src/runner.test.ts::step reports (docs/reference/specs/run-history.md item 35)::*
Re-entry: the settlement precedes the first model call with the results in the calls' order, the counters and the remaining budget come from the plan, an unrecorded step is reported first with no turns[unit] src/runner.test.ts::resume (docs/reference/specs/run-history.md item 37)::*