keel
Harness

Budgets and cancellation

Grant bounded work and preserve cancellation and deadlines through delegation.

Root turn
{ steps: 8, timeMs: 30_000 }
10 seconds elapsed ↓ 20 seconds remain
Child grant
{ steps: 4, timeMs: 25_000 }

effective timeMs: 20_000
Stop signal ↓ descendants · stop outcome ↑ callers
Delegation creates a child step grant. It does not reset the caller's deadline.

A tool loop needs an execution limit even when every individual call is valid. A delegated child also needs its own grant, without gaining more time than its caller has left.

Choose the right limit

SettingWhat it bounds
budget.stepsOrdinary model-loop iterations for this turn.
budget.timeMsOptional time ceiling for this turn; otherwise turn.wallClock.limitMs applies.
budget.outputTokensCumulative output-token grant across the whole turn.
context.outputReserveTokensOutput reserved and passed to shipped adapters on each request.
continuation.maxExtra recovery rounds after a length-limited model response.
Deliverable nudgesExtra rounds to recover a missing required submission.
Tool or delegation retriesRepeated execution attempts after failure.

The runtime's manifest declares exactly two ceilings — turn.wallClock (with an optional under naming the platform limit it must sit below) and transport.envelopeBytes, measured in UTF-8 bytes. Both are required; a missing entry refuses boot.

The model-loop step grant is not a cap on every operation. One response can contain several tool calls, a chain may retry or fall back, and recovery rounds are separately bounded. budget.outputTokens is a cumulative grant: each model request's output reserve is clamped to what remains of it, and an exhausted grant settles the turn failed with budget-cut — including a continuation or nudge round it would otherwise have funded — never a synthetic completion.

Grant work explicitly

const controller = new AbortController();

const pending = runtime.runTurn({
  agent: assistant,
  ambient,
  envelope,
  budget: { steps: 8, timeMs: 30_000 },
  hooks: { signal: controller.signal },
});

// Your UI stop handler calls controller.abort().
const outcome = await pending;

hooks carries two fields: signal for cooperative cancellation, and onEvent for observing the turn's wire. A user stop settles as stopped. Exhausting ordinary steps without a clean finish produces failed with budget-cut. A cut outcome names its cause in by: "timeout" (the time ceiling), "watchdog" (a silent tool), or "stream-error" (a provider stream that failed after content).

Preserve the caller's deadline

A child receives the smaller of its own time grant and the caller's remaining time. A child with a shorter local timeout can return failure feedback while its caller still has time to continue. Cancellation is inherited by descendants and propagates back to callers without retrying a stopped child.

Child steps are a separate grant, not automatically deducted from the parent's step count. Keep delegation depth, grants, and retries deliberate. See Delegation.

Keep tools alive or cut them

Every tool executes with a context — execute(input, ambient, ctx) where ctx is { signal, progress } — and the executor holds both ends:

  • ctx.progress(data) emits a progress part on the wire and resets the liveness window. It is the heartbeat: a tool that declares liveness: { maxSilenceMs } and then goes silent past that wall-clock ceiling has ctx.signal aborted, and the turn settles cut with by: "watchdog" instead of hanging.
  • The turn's remaining time additionally caps every tool call, liveness declared or not — a tool cannot out-sleep the wall-clock budget; exceeding it cuts the turn with by: "timeout".

The abort frees the turn even when the tool's own promise ignores the signal, but a well-behaved long-running tool passes ctx.signal to its I/O so the underlying work actually stops. Model requests remain cooperative: the runtime observes cancellation and deadlines at execution boundaries rather than forcibly terminating an in-flight provider call.

At boot, a declared turn.wallClock.under requires the runtime ceiling to sit below the platform limit — the app answers before the platform kills. This validates configuration; it cannot guarantee a response before an uninterruptible external operation finishes.

Inspect in Studio

Compare the granted steps with steps used in TURN RECORDS. Recovery can add rounds beyond the ordinary loop grant. Trigger stop and timeout separately, then check that descendants and the root have the expected outcomes — and fire a deliberately silent tool to watch the watchdog cut land in EVENTS.

Next: Failure and recovery.