DEV Community

Emery Chen
Emery Chen

Posted on

Token Caps Are Tests. Treat Them That Way.

You would not merge a job with no timeout. An agent with no token cap is the same risk.

A fluent local demo is not a bound on spend. It is theater that dies when the bill arrives.

The claim

Vibe coding is not the real engineering scandal here. Unbounded tool loops with no fuse are the scandal.

You let a model call tools until it feels done. Treat that loop as an unsigned check, not a prototype.

A cost fuse is the first honest test you can write. It fails closed and it refuses to grade prose. It only stops spend when the cap trips.

What you are pretending

You open a chat and the agent refactors a file. You screenshot the diff and call it a prototype.

You still shipped none of the controls below.

  • a hard ceiling on tokens for the run
  • a kill path that the host cannot ignore
  • a record of which machine actually ran
  • a fail code your CI already understands

Without those controls you demonstrated taste, not control. Taste does not survive contact with a retry storm.

Define the fuse before the prompt

A fuse is not a usage dashboard you glance at. A fuse is a gate that kills the process.

You set a budget and the runner enforces it. The process dies and the job goes red.

Start with three numbers and nothing fancier. Add more ceilings only after these three hold.

  1. max_prompt_tokens caps the user payload size
  2. max_completion_tokens caps what the model emits
  3. max_tool_rounds caps how often tools may run

If any ceiling trips, you fail the job. You do not retry inside the same CI job. Retries hide leaks and train you to ignore red.

Why three numbers beat one dollar cap

A single dollar cap hides the failure mode. Tokens and tool rounds fail at different layers.

Prompt tokens catch a context dump before the call. Completion tokens catch a model that will not stop. Tool rounds catch a loop that forgot the user.

One money number arrives too late to help. Providers bill after the damage is already done. Your job needs a local veto before the HTTP call.

Decision table

Signal Pass Fail closed
Tokens under cap continue n/a
Tool rounds under cap continue n/a
Cap hit mid-tool abort and dump a trace yes
Host is not disposable do not score the run yes
Budget missing from config refuse to start yes

Read the last row twice before you merge. A missing budget must be a start failure. Generous defaults are how surprise bills get born.

Pick numbers like timeouts, not like hopes

Copy timeout practice from your HTTP clients. You already pick 5s without a research paper.

Start tight, then loosen with evidence from trips. Do not start wide and promise to optimize later.

Use this tight first pass until trips teach you more.

{
  "max_prompt_tokens": 2000,
  "max_completion_tokens": 1500,
  "max_tool_rounds": 4
}
Enter fullscreen mode Exit fullscreen mode

Four tool rounds is already a long leash. Most repair jobs should die before round four. If your task needs twenty rounds, split the task.

Write the reason for each number in the PR. "Seems fine" is not a reason you can audit.

Artifact: a budget gate you can run

Treat the next file as a copyable proposal. It is not a production SDK or a vendor wrapper. It is a fuse you can trip on purpose.

# budget_gate.py — proposal, unexecuted example
from __future__ import annotations

import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path


@dataclass(frozen=True)
class Budget:
    max_prompt_tokens: int
    max_completion_tokens: int
    max_tool_rounds: int


def load_budget(path: Path) -> Budget:
    raw = json.loads(path.read_text())
    required = (
        "max_prompt_tokens",
        "max_completion_tokens",
        "max_tool_rounds",
    )
    missing = [k for k in required if k not in raw]
    if missing:
        raise SystemExit(f"budget missing keys: {missing}")
    if any(int(raw[k]) <= 0 for k in required):
        raise SystemExit("budget values must be positive")
    return Budget(
        max_prompt_tokens=int(raw["max_prompt_tokens"]),
        max_completion_tokens=int(raw["max_completion_tokens"]),
        max_tool_rounds=int(raw["max_tool_rounds"]),
    )


class Fuse:
    def __init__(self, budget: Budget) -> None:
        self.budget = budget
        self.prompt_tokens = 0
        self.completion_tokens = 0
        self.tool_rounds = 0

    def charge_prompt(self, n: int) -> None:
        self.prompt_tokens += n
        if self.prompt_tokens > self.budget.max_prompt_tokens:
            self._trip("prompt")

    def charge_completion(self, n: int) -> None:
        self.completion_tokens += n
        if self.completion_tokens > self.budget.max_completion_tokens:
            self._trip("completion")

    def charge_tool_round(self) -> None:
        self.tool_rounds += 1
        if self.tool_rounds > self.budget.max_tool_rounds:
            self._trip("tool_rounds")

    def snapshot(self) -> dict:
        return {
            "prompt_tokens": self.prompt_tokens,
            "completion_tokens": self.completion_tokens,
            "tool_rounds": self.tool_rounds,
            "hostname": os.uname().nodename,
            "utc": datetime.now(timezone.utc).isoformat(),
        }

    def _trip(self, reason: str) -> None:
        dump = {"tripped": reason, **self.snapshot()}
        Path("fuse-trip.json").write_text(json.dumps(dump, indent=2))
        print(f"FUSE TRIPPED: {reason}", file=sys.stderr)
        raise SystemExit(2)


def estimate_tokens(text: str) -> int:
    # Crude stand-in. Replace with your tokenizer.
    return max(1, len(text) // 4)


def main() -> None:
    budget = load_budget(Path(os.environ.get("BUDGET_FILE", "budget.json")))
    fuse = Fuse(budget)
    prompt = Path("prompt.txt").read_text()
    fuse.charge_prompt(estimate_tokens(prompt))
    # Your agent loop would charge completion and tools here.
    print("budget accepted; runner may start")
    print(json.dumps(fuse.snapshot()))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Pair the gate with a tiny committed config file. Keep budget.json next to the runner, not in chat history.

Run the gate like a unit test, not a chat.

printf 'summarize src/app.ts in five bullets\n' > prompt.txt
export BUDGET_FILE=./budget.json
python budget_gate.py
echo $?   # 0 means the fuse armed
Enter fullscreen mode Exit fullscreen mode

Trip the fuse on purpose in the same session. A fuse you never trip is only décor.

python - <<'PY'
from pathlib import Path
Path("prompt.txt").write_text("word " * 20000)
PY
python budget_gate.py; echo exit:$?
test -f fuse-trip.json && cat fuse-trip.json
Enter fullscreen mode Exit fullscreen mode

You want exit code 2 and a trip file. Silence from the runner means the gate never loaded.

A second test: tool rounds

Token caps miss a cheap infinite tool loop. Each tool result can be small and still endless.

Extend the runner with an explicit round counter. Fail before the fifth tool call in this example.

# proposal: drop into your loop, unexecuted
for _ in range(99):
    fuse.charge_tool_round()
    # call_tool() would go here
Enter fullscreen mode Exit fullscreen mode

If that loop can run to 99, your fuse is dead. Watch the exit code, not the model apology.

Why the host must not be your laptop

A fuse that lives only in your shell is optional. Optional gates get skipped the moment a demo is due.

Copy the same files onto a disposable server. Keep the budget file and keep the exit codes. If the fuse cannot kill that process, stop scoring.

Your laptop has aliases, extra env, and cached keys. Those extras make a red fuse look green. A clean server removes the accidental safety net.

This is where free model access earns its keep. You rehearse the kill path without a paid key. You still need a machine you can wipe afterward.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open source project with free model access. It also offers a free server option for rehearsal. Use both as a ring you can delete, not an SLA.

Keep the product out of the work's real title. The work is the fuse, not the vendor.

Wire the fuse into the job

A useful CI step should look almost boring. Clever gates get skipped when people are late. Boring gates still run when people are late.

# .github/workflows/agent-fuse.yml — proposal
name: agent-fuse
on:
  pull_request:
    paths:
      - "agent/**"
      - "budget.json"
      - "budget_gate.py"
      - "prompt.txt"
jobs:
  fuse:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Arm budget gate
        run: python budget_gate.py
      - name: Refuse a trip on the happy path
        run: test ! -f fuse-trip.json
Enter fullscreen mode Exit fullscreen mode

Add a second job that expects a trip. Green builds that never see red teach nothing.

# expected-fail.sh — proposal
set -euo pipefail
python -c 'import json,pathlib; p=pathlib.Path("budget.json"); b=json.loads(p.read_text()); b["max_prompt_tokens"]=1; p.write_text(json.dumps(b))'
if python budget_gate.py; then
  echo "fuse failed to trip" >&2
  exit 1
fi
test -f fuse-trip.json
Enter fullscreen mode Exit fullscreen mode

You now have two outcomes worth merging later.

  • the happy path stays under the committed budget
  • the angry path dies with exit code 2

Everything else is still just chat with extra steps.

Log the trip like an incident

When the fuse trips, keep a small packet. Do not keep a novel. Keep the indictment.

Write these fields into fuse-trip.json every time:

  • reason: prompt, completion, or tool_rounds
  • the three counters at death
  • git sha of the budget file
  • hostname of the runner
  • utc timestamp

Hostname matters because laptop scores do not transfer. If hostname looks like a personal laptop, discard the run. Personal hostnames are a smell, not a platform.

What this does not measure

A token cap is not a quality score at all. It will not catch a wrong refactor in review. It will not catch a secret that leaked into logs.

It will not catch a tool the model should never see. It measures boundedness and almost nothing else.

Do not park a prose scorer next to this fuse. You will start negotiating with the generated essay. Keep the fuse stupid so it stays enforceable.

Who should not use this

Skip this approach when any line below is true.

  • you ship no agent loop at all
  • your tools can mutate prod with one call
  • you need latency SLOs, not rehearsal
  • you cannot store traces even on a throwaway host
  • you treat free model access as production capacity

If tools can mutate production, isolate them first. A fuse on a live admin API is theater again. Fix the blast radius, then add the token cap.

Limitations you must name

Token estimates by character count will drift under load. Replace estimate_tokens with your real tokenizer soon. Provider usage fields also lie when streams cut off.

Persist the raw usage object beside fuse-trip.json. Audit the object before you trust any dashboard.

Free model access can be slow, queued, or withdrawn. A free server is not your VPC or your region. Rehearse on the free path. Certify on your own.

Do not advertise a fuse you cannot trip in CI. An untested kill switch is only documentation.

Queueing on a free path can starve the fuse test. A hung job is not a passed budget. Put a wall-clock timeout around the whole job.

Thirty minutes of silence is not a passed bound. Kill the runner, then read the trip file.

The opinion, restated

You are not behind on models this quarter. You are behind on bounds you can enforce.

The argument about whether AI codes better is noise. Better than whom, on which host, under which cap?

If you cannot name the budget, you cannot name the system. If you cannot kill the process, you do not own it.

Put the fuse in the repo before the next prompt. Run it on a box you can burn without regret. Then you can talk about agents without pretending.

You can rehearse the kill path on a throwaway ring. MonkeyCode's free models and free server are enough for that.

Top comments (0)