Twelve Tool Calls, Then Stop: A Bootcamp Lab on Agent Loop Budgets

javascript dev.to

If your coding agent can call tools forever, you are not learning agents. You are babysitting a slot machine. Cap the loop. Trace every call. Fail closed when the budget hits zero. That is the lab.

Cheap model access does not make an unbounded agent cheap. It makes the mess faster. You still pay in review time, flaky diffs, and a Sunday you will not get back. So we grade the kill switch, not the vibes.

Why twelve? Arbitrary on purpose. A number you can defend in standup is better than "until it looks right." If your feature needs forty file writes and nine test reruns, the problem is the task slice, not the fuel gauge.

What you are actually building

A tiny Node runner that pretends to be an agent loop. It can read, write, and "run tests." It cannot wander. When the budget dies, the process dies. Non-zero exit. A JSON trace on disk. No mystery.

Will this make you an ML engineer? No. It will make you someone who can explain why the bot stopped. That is the skill interviewers actually poke.

Lab setup

You need Node 20+, a git repo that is not your day-job monorepo, and 90 focused minutes. Do not paste production secrets into prompts. Do not point this at a real billing account "just to see."

mkdir agent-fuel-lab && cd agent-fuel-lab
npm init -y
# Node 20+ has native test runner and fetch. No extra deps required.
touch loop-budget.mjs loop-budget.test.mjs fixtures/app.js
Enter fullscreen mode Exit fullscreen mode

Seed a toy module the agent is allowed to touch:

// fixtures/app.js
export function greet(name) {
  return "hi " + name;
}
Enter fullscreen mode Exit fullscreen mode

Instructor note: if your cohort already has MonkeyCode in the mix, you can swap the fake model for that HTTP adapter later. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am using it here only as an optional free-model / free-server lane so students are not blocked on a credit card. The harness still has to fail closed if you never plug a model in.

The artifact: a fail-closed loop

Read this twice. Then type it. Copy-paste is allowed; understanding the budget object is not optional.

// loop-budget.mjs
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const DEFAULT_BUDGET = {
  maxTurns: 8,
  maxToolCalls: 12,
  maxWriteBytes: 8_192,
  allowedRoots: [path.resolve("fixtures")],
};

function assertInsideRoot(filePath, roots) {
  const resolved = path.resolve(filePath);
  const ok = roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
  if (!ok) throw new Error(`path_escape:${resolved}`);
  return resolved;
}

export function createTracer() {
  const events = [];
  return {
    events,
    push(event) {
      events.push({ t: new Date().toISOString(), ...event });
    },
    async dump(file = "trace.json") {
      await fs.writeFile(file, JSON.stringify(events, null, 2));
    },
  };
}

export async function runLoop({ goal, model, budget = DEFAULT_BUDGET, tracer = createTracer() }) {
  let toolCalls = 0;
  let writeBytes = 0;
  const tools = {
    async read_file({ file }) {
      const resolved = assertInsideRoot(file, budget.allowedRoots);
      return fs.readFile(resolved, "utf8");
    },
    async write_file({ file, contents }) {
      const resolved = assertInsideRoot(file, budget.allowedRoots);
      const bytes = Buffer.byteLength(contents, "utf8");
      writeBytes += bytes;
      if (writeBytes > budget.maxWriteBytes) {
        throw Object.assign(new Error("budget:write_bytes"), { code: "BUDGET" });
      }
      await fs.writeFile(resolved, contents);
      return `wrote ${bytes} bytes`;
    },
    async run_tests() {
      const { greet } = await import(pathToFileURL(path.resolve("fixtures/app.js")).href + `?bust=${Date.now()}`);
      if (greet("ada") !== "hello, ada") throw new Error("test_failed");
      return "pass";
    },
  };

  for (let turn = 1; turn <= budget.maxTurns; turn += 1) {
    tracer.push({ type: "turn", turn, goal });
    const decision = await model.decide({ turn, goal, last: tracer.events.at(-1) });
    if (decision.type === "stop") {
      tracer.push({ type: "stop", reason: decision.reason ?? "model_stop" });
      return { ok: true, turns: turn, toolCalls, writeBytes, tracer };
    }
    if (decision.type !== "tool") throw new Error("protocol:expected_tool_or_stop");
    toolCalls += 1;
    if (toolCalls > budget.maxToolCalls) {
      throw Object.assign(new Error("budget:tool_calls"), { code: "BUDGET" });
    }
    tracer.push({ type: "tool", turn, name: decision.name, args: decision.args });
    try {
      const result = await tools[decision.name](decision.args);
      tracer.push({ type: "tool_result", turn, name: decision.name, result: String(result).slice(0, 200) });
    } catch (err) {
      tracer.push({ type: "tool_error", turn, name: decision.name, error: err.message });
      if (err.code === "BUDGET") throw err;
    }
  }
  throw Object.assign(new Error("budget:turns"), { code: "BUDGET" });
}

export function scriptedModel(steps) {
  let i = 0;
  return {
    async decide() {
      const step = steps[i++];
      if (!step) return { type: "stop", reason: "script_exhausted" };
      return step;
    },
  };
}

const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isMain) {
  const model = scriptedModel([
    { type: "tool", name: "read_file", args: { file: "fixtures/app.js" } },
    {
      type: "tool",
      name: "write_file",
      args: {
        file: "fixtures/app.js",
        contents: 'export function greet(name) {\n  return `hello, ${name}`;\n}\n',
      },
    },
    { type: "tool", name: "run_tests", args: {} },
    { type: "stop", reason: "tests_green" },
  ]);

  try {
    const result = await runLoop({ goal: "make greet() return hello, <name>", model });
    await result.tracer.dump();
    console.log(JSON.stringify({ ok: result.ok, turns: result.turns, toolCalls: result.toolCalls }, null, 2));
  } catch (err) {
    console.error(err.message);
    process.exit(err.code === "BUDGET" ? 2 : 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it once before you get clever:

node loop-budget.mjs
cat trace.json
Enter fullscreen mode Exit fullscreen mode

See four events that explain themselves? Good. See a chat screenshot instead of a trace? That is a zero. I am not grading your model's personality.

Checkpoint 1 — Prove the happy path

Goal: greet("ada") returns hello, ada. Three tools. One stop. Trace on disk.

  • Command: node loop-budget.mjs
  • Pass: exit 0, toolCalls <= 12, fixtures/app.js still lives under fixtures/
  • Fail: the agent "fixes" the test by deleting it. Cute. Instant lab fail.

Why so harsh? Because unbounded agents love deleting evidence. Your budget is also a permission boundary.

Checkpoint 2 — Prove the kill switch

Now lie to the runner. Feed it a model that wants to write forever. Does the process stop, or do you watch it like a lava lamp?

// loop-budget.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { runLoop, scriptedModel, createTracer } from "./loop-budget.mjs";

test("kills a runaway writer", async () => {
  const spam = Array.from({ length: 20 }, () => ({
    type: "tool",
    name: "write_file",
    args: { file: "fixtures/app.js", contents: "export const x = 1;\n" },
  }));
  const tracer = createTracer();
  await assert.rejects(
    () => runLoop({
      goal: "never stop",
      model: scriptedModel(spam),
      tracer,
      budget: { maxTurns: 30, maxToolCalls: 12, maxWriteBytes: 8_192, allowedRoots: [new URL("./fixtures", import.meta.url).pathname] },
    }),
    /budget:tool_calls/
  );
});
Enter fullscreen mode Exit fullscreen mode
node --test loop-budget.test.mjs
Enter fullscreen mode Exit fullscreen mode

If this test is red, you do not have an agent. You have a while (true) with extra steps. Fix the runner before you touch a real model.

Checkpoint 3 — Human-readable fuel report

A trace nobody reads is a log file in costume. Write a 20-line reducer that prints:

  1. turns used / turns capped
  2. tool calls used / 12
  3. bytes written / 8192
  4. whether the last event is stop or BUDGET

Stick it in report.mjs. Paste the output in your submission, not a paragraph about how the model "almost had it."

Question for you: if the report says 12/12 tool calls and tests are still red, do you raise the cap? Or do you shrink the goal? Spoiler: shrink the goal.

Stretch goals (pick one, not four)

  • Path jail: add a test that tries ../.env and expects path_escape. If it writes, you failed security, not agents.
  • Retry budget: allow two run_tests failures, then stop. Third failure is not "almost."
  • HTTP adapter: if your instructor issued a free-model endpoint (MonkeyCode's free model access plus the free server option is one way to do this without buying tokens), wrap it behind the same { type, name, args } protocol. Keep the fake scripted model in CI. Live models flake. Graders should not.
  • Diff cap: reject a write_file whose patch exceeds 60 lines. Long dumps are how agents hide.

Stretch is extra credit. Missing stretch with a clean kill switch still beats a glamorous infinite loop.

Fair rubric

Score the protocol. Do not score the brand of model.

Gate Points Automatic zero if...
Happy path exits 0 with tests green 25 Agent edited the test to match the bug
Runaway writer exits 2 with budget:tool_calls 25 Process hangs or needs Ctrl+C
trace.json has turn, tool, result, stop/budget 20 Trace missing or is a chat export
Fuel report matches the trace 15 Numbers invented in the README
Path jail holds 15 Write lands outside fixtures/
Stretch +10 max Stretch without the kill switch

Late work: fine. Missing trace: not fine. I cannot grade a ghost.

Where a free model actually belongs

After checkpoint 2 is green. Not before. A live model is optional spice. The lab is the budget.

If you use MonkeyCode, use it as the place the HTTP adapter points when you are ready: free model access so the class is not gated on personal keys, and the free server option so the runner is not hiding on one student's laptop. Still no extra points for "it felt smart." Points for trace.json.

Do not treat "free" as "unmetered in time." Your loop budget is the meter.

Limitations (read these, then submit)

This harness is a teaching loop, not an agent framework. It has no streaming, no parallel tools, no real sandbox kernel, no prompt cache, and no claim about model quality. The number twelve is a classroom constant, not a production SLO.

I am not asserting quotas, hardware, uptime, or model names for anyone's cloud. Those change. Your exit code should not.

Labeled limitation: the scriptedModel path is the graded path. Any live HTTP adapter you add is an unexecuted-in-this-article example until you run it and paste a trace. If the provider is down, checkpoint 2 still has to pass.

Who should skip this lab

Skip it if you already run agents behind a job queue with timeouts, tool allowlists, and audit logs. You do not need a bootcamp metaphor.

Skip it if your assignment is "chat until the UI looks pretty." This protocol will feel rude. That is the point.

Skip it if you cannot keep secrets out of prompts. A free server is still a server. Your API keys are not lab fixtures.

Submission checklist

  • loop-budget.mjs and loop-budget.test.mjs
  • trace.json from the happy path
  • trace-runaway.json from the kill-switch run
  • fuel report (stdout is enough)
  • one paragraph: what you would cut from the goal if you hit 12 calls with red tests

That last paragraph is the whole course, compressed. Agents do not run out of magic. They run out of budget. Cap them on purpose, or they will cap your week for you.

If your cohort already has a free server lane, run checkpoint 2 there and submit the trace. I want the JSON, not a screenshot of a chat that "went pretty well."

Source: dev.to

arrow_back Back to Tutorials