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.
- Code:
src/runner.ts, budgets onsrc/agents/registry.ts - Docs: The agents and their toolsets, How a request flows
- Tests:
src/runner.test.ts
Behavior
Wall clock is the real budget; turns are a backstop. Each agent defines
maxMinutes(hard deadline) andmaxTurns(runaway guard).update_status-only turns don't consume turns; an absolute iteration cap ofmaxTurns × 2bounds the loop regardless.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.
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.finaleTimeoutMsfor 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).Refusals surface as a clear user-facing message suggesting rephrase/model-switch; token-limit truncation is labeled, never silent.
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.
Per-run system override:
RunOptions.systemreplacesagent.systemfor 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 sharedAgentDef(concurrent dispatches share it). No override →agent.system, unchanged.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
ExecInfraErrorfor it, while a nonzero exit is returned as ordinary output. The runner watches sandbox health through the executor seam (ExecHealthTracker): afterMAX_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 lastExecInfraErrormessage, 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: anExecCapacityError(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 typedfleet_busynote 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).Operator stop — soft and hard: every run owns a
RunControl(minted byRunRegistry.create(), passed asRunOptions.control); an operator drives it from/runs(see live-view.md item 10). Soft — the loop condition checkscontrol.requestedbefore 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:. Hard —control.hardSignalis anAbortSignalthe runner threads into everyprovider.complete(CompletionRequest.signal) and into tools viaToolContext.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 withrelease("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 typedrun_notes (stop_requestedfrom the registry,stoppedfrom the runner, each carryingmode), 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 inrunAgentand turned into the abort message. Nocontrol(CLI, tests) → the loop is byte-for-byte the loop without stops (no signal is handed to the provider).Side-effect-free tools in one turn run concurrently. A tool declares
sideEffectFree: truewhen 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 severaltool_useblocks, the runner executes each maximal run of consecutive side-effect-free calls withPromise.allSettledand every other call alone, in the model's order — so fiveread_files cost one resident round trip, not five, whilebashnever overlaps anything. The model sees no difference:tool_resultparts are appended in the model's order regardless of completion order, somessagesis byte-identical to the serial loop; on the stream the batch'stool_callevents are emitted before itstool_results (paired bycallId). A hard stop mid-batch unwinds once — the sibling rejections are collected, never left unhandled.Prompt caching on the Anthropic adapter (
buildAnthropicParams, pure): fourcache_control: ephemeralbreakpoints 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.cacheReadTokenson theturnevent shows it. The request's ownmessagesare never mutated (breakpoints are set on the mapped copy).Cache TTL per agent, thinking replayed, effort the model accepts. Builds on item 10. TTL: every breakpoint carries
CompletionRequest.cacheTtl, set fromAgentDef.cacheTtland passed by the runner on every call including the finale —5mby default (a read refreshes the entry free, strictly cheaper while requests start < 5 min apart),1hforcoding(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 on5m. A rolling breakpoint lands on the last block that can carrycache_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_thinkingblocks are kept inCompletionResult.contentas opaqueContentParts 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;collectTextskips them, nothing renders them, the OpenAI-compatible adapter drops them. Effort: levels arelow | medium | high | xhigh | max(src/effort.ts);effortFor(model, effort)omits the parameter on Haiku/pre-4 models (400) and clampsxhigh/max→highon Opus/Sonnet ≤ 4.6 (400 there; dated bare-major ids likeclaude-sonnet-4-20250514count as 4.x), so amodel:override degrades instead of failing. Pinned per run (an effort change invalidates the messages cache).@anthropic-ai/sdk0.39 → 0.122 for the typedcache_control.ttl,output_config.effortand 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.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 firsttool.run— with aStepReport: 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 seedmessages), the index of the first of them, the calls about to be dispatched (callId, tool name), andturn/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.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'stool_resultsummary 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 aprompt is too long400 that ends the run.Re-entry from a transcript (run-history.md item 37). With
RunOptions.resume,messagesis a reclaimed run's transcript and the loop starts where it stopped: the deadline isnow + remainingMsfrom the last step record (never the agent's whole budget again),turnand the loop index come from the plan, aresumedrun 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 throughonStepwith no new turns before its calls run. Absent → a fresh run, byte-identical to before.An answer written alongside a bookkeeping call is the answer. Text that rides with a
tool_useis narration (anassistantevent before the tool rows it explains), never the answer — right for a real tool, wrong forupdate_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 everytool_useisupdate_statusis HELD, not narrated. The next completion decides: empty text-only → the held text is the answer (noassistantevent; 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
| Criterion | Proof |
|---|---|
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)::* |