A bounded agent loop with the three hard limits that keep an autonomous loop from draining an API budget.
An autonomous loop — "do work, check if it's good enough, repeat" — is only worth running when the check is cheaper than the work, and only safe to run when it has more than one way out. "Goal met" is the happy path; the other three exits (a wall on iterations, a wallet on tokens/dollars, and a stall detector) are what stop a loop from quietly burning money on a goal it will never reach. This is a tiny, dependency-free, fully-typed implementation of that pattern.
Backs the post: How to Run an Agent Loop Without Burning Your Token Budget on The Cloud Codex.
git clone https://github.com/michaeltuszynski/agent-loop.git
cd agent-loop
npm installRequires Node 20+. The core (src/loop.ts) has zero runtime dependencies and no LLM coupling.
Runs offline, no API key, prints a summary table:
npm run exampleYou'll see four scenarios, each deliberately tuned to leave the loop a different way:
agent-loop — four scenarios, four distinct exits
┌─────────┬─────────────┬────────────────────┬────────────┬─────────────┬───────────┬───────────┐
│ (index) │ scenario │ status │ iterations │ failingLeft │ tokens │ cost │
├─────────┼─────────────┼────────────────────┼────────────┼─────────────┼───────────┼───────────┤
│ 0 │ 'converges' │ 'done' │ 4 │ 0 │ '6,000' │ '—' │
│ 1 │ 'grindy' │ 'max-iterations' │ 6 │ 28 │ '9,000' │ '—' │
│ 2 │ 'plateau' │ 'stalled' │ 4 │ 4 │ '6,000' │ '—' │
│ 3 │ 'expensive' │ 'budget-exhausted' │ 4 │ 80 │ '240,000' │ '$1.2000' │
└─────────┴─────────────┴────────────────────┴────────────┴─────────────┴───────────┴───────────┘
The numbers are deterministic — you'll see the same table every run.
You give runLoop four things: a starting state, a step that does one unit of work (and reports the tokens it cost), a cheap check that scores progress and decides "done", and the limits that bound the whole thing. Each iteration runs step then check; the loop exits the moment it hits any one of four conditions.
type StepResult<S> = { state: S; tokensUsed: number; inputTokens?: number; outputTokens?: number };
type CheckResult = { done: boolean; signal: number }; // signal = progress score; higher is better
interface Limits {
maxIterations: number; // the wall
maxTokens?: number; // the wallet (tokens)
maxCostUsd?: number; // the wallet (dollars; needs `pricing`)
stallRounds?: number; // give up after N checks with no improvement
}
async function runLoop<S>(opts: {
initialState: S;
step: (state: S, iter: number) => Promise<StepResult<S>>;
check: (state: S) => Promise<CheckResult>;
limits: Limits;
pricing?: Pricing; // { inputPerMTok, outputPerMTok } — enables costUsd + maxCostUsd
onIteration?: (info) => void;
}): Promise<LoopResult<S>>;The result tells you which of the four exits fired — that's the whole point:
type LoopStatus = 'done' | 'max-iterations' | 'budget-exhausted' | 'stalled';The order of operations per iteration is deliberate:
- Guard before spending. If another step would cross the wall or the wallet, stop now — the cheapest token is the one you never spend.
step— do the work, accumulate tokens and cost.check— score progress; ifdone, stop'done'.- Stall — track the best
signalseen; if it hasn't improved forstallRoundsconsecutive checks, stop'stalled'.
Stall detection is what catches the silent failure mode: a loop that keeps spending but stopped making progress three turns ago. Without it, you only ever stop on success or on a hard limit — and the hard limit is the expensive way to find out you were stuck.
Wiring step to a real model is the same shape — see examples/anthropic.ts, which drives Claude toward a checkable goal (produce an N-letter palindrome) and accounts real token cost. It needs ANTHROPIC_API_KEY; without one it prints a message and exits cleanly. The Anthropic SDK is a devDependency and never loads on the default test path.
- The check isn't cheaper than the work. This whole pattern only pays off when verifying a result costs far less than producing it. If your
checkis itself an expensive model call (an "LLM judge" that costs as much as the step), you've doubled your spend per iteration, not bounded it. Either find a cheap objective check or don't loop. - There's no meaningful progress signal. Stall detection needs a
signalthat actually moves as the work improves. If your task is pass/fail with no gradient (it's either perfect or it's nothing),signalis justdone ? 1 : 0, stall detection can't help, and you're relying entirely on the iteration/budget walls. - You need retries, backoff, or rate-limit handling. This is a budget loop, not a resilience loop. It assumes
stepeither succeeds or throws; it does not catch errors, retry transient failures, or respect429retry-after headers. Wrap yourstep(or use your SDK's built-in retries) for that. - The work isn't actually iterative. If one call gets you the answer, a loop is overhead. Reach for this only when each step genuinely builds on the last (agent refines toward a goal, fixes more of a failing suite, narrows a search).
- Concurrency / parallel fan-out.
runLoopis strictly sequential — one step at a time. It is not the right tool for "run 50 candidates in parallel and pick the best."
npm test # node:test via tsx — runs offline, no key, no network
npm run lint # Biome
npm run typecheckTests live in test/ and cover one case per exit status, the cost-accounting math, and stall detection across N rounds.
MIT — see LICENSE.