Dear pre-loop me,
You will lose a day. Not to a hard bug. To bookkeeping you skipped.
You will point an agent at a task, watch it loop twelve turns, and have no idea what it spent. Then you will try again on a different machine and learn nothing new. Here is the letter I wish I had received, written in the order the mistakes actually happened.
Mistake 1: You counted output tokens and ignored the input
You assumed the expensive part is what the model writes back. It is not. Every turn resends the whole conversation. So prompt tokens grow with every message you append.
With a constant base prompt and a fixed per-turn growth, total prompt tokens look like this:
prompt_total(n) = n * base + growth * n * (n - 1) / 2
The second term is quadratic. That is why turn 10 costs far more than turn 1, and why a "small" loop is not small.
Here is the estimator, as a plain function you can unit-test:
// estimate.mjs
export function estimatePromptTokens({ turns, base, growth }) {
if (turns <= 0) return 0;
return turns * base + (growth * turns * (turns - 1)) / 2;
}
// Example shape only - plug in your own measured base and growth.
const plan = { turns: 12, base: 1200, growth: 700 };
console.log(estimatePromptTokens(plan));
The lesson: measure base and growth on turn one and two. Then you can forecast the whole run before you start it.
Mistake 2: You treated free capacity as unlimited concurrency
Free tiers are shared tiers. Under concurrency you will meet 429, and you will meet it at the worst moment.
You will then do the thing that costs the most: retry immediately in a tight loop. That converts one rate-limit event into a burst, and the burst into a longer cooldown.
What you want is a single-flight queue with jittered exponential backoff, and a hard cap on attempts.
// guard.mjs
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function withRetry(fn, { attempts = 4, baseMs = 1000 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (err.status !== 429) throw err;
const wait = baseMs * 2 ** i + Math.random() * 250;
console.log(`429 -> retry ${i + 1}/${attempts} in ${Math.round(wait)}ms`);
await sleep(wait);
}
}
throw lastErr;
}
Two rules follow from this. Run one loop at a time when you are measuring. And treat the retry cap as part of the budget, not as an afterthought.
Mistake 3: You ran the loop straight on your laptop
You let an agent write files, install packages, and run shell commands on the same machine that holds your SSH keys. Nothing broke. That is luck, not design.
The cheap fix is a disposable box. Ask the box three questions before it touches anything:
pwd && id -un && uname -sm
git status --porcelain
git rev-parse --short HEAD
If git status is not clean, stop. If the branch is not a scratch branch, stop. If the working directory is not what you expected, stop.
This is where the free server option matters. You get a separate machine to run the loop, so a bad rm costs you a container, not your home directory. MonkeyCode offers free model access and a free server option; both are useful exactly here, as a disposable execution surface rather than a place to keep state.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The correct habit is to treat the remote box as stateless. Clone, run, collect the ledger, throw the box away. Never leave a long-lived secret on it.
The artifact: a ledger that stops the loop
Here is the whole harness in one file. It wraps your model call, records usage per turn, and aborts when the budget is spent. It is transport-agnostic: you pass in a call function, so it does not depend on any vendor's SDK shape.
// ledger.mjs
import { withRetry } from './guard.mjs';
const rows = [];
export function record(turn, usage = {}) {
const prompt = usage.prompt_tokens ?? 0;
const completion = usage.completion_tokens ?? 0;
rows.push({ turn, prompt, completion });
return prompt + completion;
}
export function totals() {
const t = rows.reduce(
(a, r) => ({ p: a.p + r.prompt, c: a.c + r.completion }),
{ p: 0, c: 0 }
);
return { prompt: t.p, completion: t.c, all: t.p + t.c, calls: rows.length };
}
export async function runLoop({
call,
maxTurns = 12,
budgetTokens = 200_000,
maxGrowthRatio = 1.8,
}) {
const messages = [{ role: 'user', content: 'Start. Report progress each turn.' }];
let prevPrompt = 0;
for (let turn = 1; turn <= maxTurns; turn++) {
if (totals().all >= budgetTokens) {
console.log(`[stop] budget ${budgetTokens} hit at turn ${turn}`, totals());
return totals();
}
const { text, usage } = await withRetry(() => call(messages));
record(turn, usage);
messages.push({ role: 'assistant', content: text });
const prompt = usage?.prompt_tokens ?? 0;
if (prevPrompt > 0 && prompt > prevPrompt * maxGrowthRatio) {
console.log(`[warn] prompt grew fast at turn ${turn}: ${prevPrompt} -> ${prompt}`);
}
prevPrompt = prompt;
console.log(`[turn ${turn}]`, totals());
}
return totals();
}
The growth warning is the part that earned its keep. It fires early, when the loop is still cheap to kill.
Run it once for two turns as a probe. Read the numbers. Then decide whether the full run is worth starting.
Decision table
| Situation | Local machine | Free remote box |
|---|---|---|
| Reading and editing your own repo | Fine | Overkill |
| Running generated shell commands | Avoid | Prefer |
| Loop you will abort often | Avoid | Prefer |
| Needs secrets or persistent state | Local, scoped | Not a fit |
| Needs GPU-shaped workloads | No | Check limits first |
| Situation | Measure first | Just run |
|---|---|---|
| New prompt, unknown context size | Yes | No |
| Repeat of a run you already ledgered | No | Yes |
| Output that will be reviewed by a human | No | Yes |
Limitations and who should skip this
This harness does not tell you whether the output is correct. It counts tokens and stops. Quality still needs a human or a test suite.
The estimator assumes steady growth per turn. Real loops lurch, so treat the forecast as a ceiling check, not a promise.
Retry logic here is deliberately simple. Multi-worker systems need a real queue with shared state.
Skip this if your task is a single request. The wrapper is pure overhead for one call. Skip it too if you cannot read usage fields from your provider; a ledger of zeros is worse than none.
One more thing. Free tiers change. Do not trust any number you read in an article, including this one. Check the project's current pages for limits, then write those limits into budgetTokens as a constant you can diff.
What I would do again, in order
- Write
estimatePromptTokensand test it with two fake turn counts. - Run two real turns on a scratch branch. Record
baseandgrowth. - Set
budgetTokensbelow the published limit, not equal to it. - Move execution to a disposable box and snapshot
git statusfirst. - Run the loop. Read the per-turn ledger, not just the final number.
That is the whole day I lost. Not to a model, and not to a bug. To three missing numbers.
Measure first. The loop can wait.
If you want to try the disposable-box version, the MonkeyCode project pages are the place to check current free model access and free server terms before you set your budget. Verify the limits yourself, then leave the box empty when you are done.
— Quinn
Top comments (0)