Bounded AI agent loop with step, token, time and error budgets
Check every budget before each step, share one AbortSignal with the model call and the tools, and end with a typed out_of_budget that names the limit.
Check every budget before each step, cap the model's output at the tokens that remain, share one AbortSignal with the model and the tools, and count consecutive tool errors. A spent budget ends the run as a typed out_of_budget naming the limit.
type Budget = { maxSteps: number; maxTokens: number; wallClockMs: number; maxToolErrors: number };
type Limit = "steps" | "tokens" | "wall_clock" | "tool_errors";
type Stop =
| { kind: "done"; summary: string } | { kind: "blocked"; reason: string }
| { kind: "failed"; error: string } | { kind: "out_of_budget"; limit: Limit };
type Turn = { tokens: number } &
({ type: "call"; tool: string; args: unknown } | { type: "halt"; stop: Stop });
type Deps = {
next(history: readonly unknown[], opts: { maxTokens: number; signal: AbortSignal }): Promise<Turn>;
tools: ReadonlyMap<string, (args: unknown, signal: AbortSignal) => Promise<unknown>>;
};
export async function runTask({ next, tools }: Deps, budget: Budget): Promise<Stop> {
const signal = AbortSignal.timeout(budget.wallClockMs);
const history: unknown[] = [];
let tokens = 0, errors = 0;
for (let step = 1; step <= budget.maxSteps; step++) {
if (signal.aborted) return { kind: "out_of_budget", limit: "wall_clock" };
if (tokens >= budget.maxTokens) return { kind: "out_of_budget", limit: "tokens" };
let turn: Turn;
try {
turn = await next(history, { maxTokens: budget.maxTokens - tokens, signal });
} catch (e) {
if (signal.aborted) return { kind: "out_of_budget", limit: "wall_clock" };
return { kind: "failed", error: String(e) };
}
tokens += turn.tokens;
if (turn.type === "halt") return turn.stop;
try {
const run = tools.get(turn.tool);
if (!run) throw new Error(`unknown tool: ${turn.tool}`);
history.push({ tool: turn.tool, ok: true, result: await run(turn.args, signal) });
errors = 0;
} catch (e) {
history.push({ tool: turn.tool, ok: false, error: String(e) });
if (++errors > budget.maxToolErrors) return { kind: "out_of_budget", limit: "tool_errors" };
}
}
return { kind: "out_of_budget", limit: "steps" };
}Why does it work?
- Checks come first. Overshoot is bounded by one model call: the remaining tokens cap its output, though its input size is known only afterward.
- One signal covers both. After the deadline, an error from
nextmeanswall_clock, notfailed; a call that ignores the signal is caught by the next check. - Tool errors are budgeted. They return to the model through
history. The counter resets on success: a model correcting itself forever is a retry storm. - Tools live in a
Map, because names come from the model; a plain object would resolveconstructor.
When should you not use it?
- Skeleton only: add the note's ledger, idempotency keys and irreversible-tool gate before any tool writes.
- For a spike watched in the editor, supervision is the control.