An agent that never stops is not deep. It is unfinished work. In this lab I fail any submission that cannot name why the loop died.
You have seen the nearby failure modes. Tests the model wrote for itself. Tools that keep changing shape. Green on a laptop and red on the grader. This one is smaller, and students still miss it.
What was the last action? How many tool calls? Who killed the process? If you cannot answer, I cannot grade you. Why would I?
The problem in the room
Students wrap a model in a while (true). The model asks for another tool. Then another. A mock search returns “refine your query.” The loop is polite. It never throws.
Then someone says the agent is “still thinking.” Still thinking is not a grade. It is a hung Node process chewing a shared machine until a human notices.
I want a halt contract. Not a vibe.
A halt contract is a recorded reason the loop ended, plus proof it ended inside a budget. Steps. Wall clock. That is the whole product of this lab.
What you will ship
You will ship a tiny JavaScript harness that runs one task and writes a halt record. I read that file. I do not read your chat screenshot.
The harness must include:
-
max_steps— a hard cap on model turns -
wall_clock_ms— a hard cap on real time -
halt_reason— one value from a frozen enum -
steps_usedandtool_calls - a JSON file on disk, because stdout lies when the process is killed
If the file is missing, the grade is zero. No oral extra credit.
Setup
You need Node 20+. You need a way to call a chat model. You do not need a paid key to complete the checkpoints.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I run this lab with MonkeyCode's free model access and the free server option so the kill switch lives on a machine the grader controls. Your laptop’s Ctrl+C is not evidence. The wall clock on the grader is.
Already have another endpoint? Use it. The harness must not care which model sits behind callModel. That is the point of grading the loop, not the vendor.
node --version
mkdir halt-lab && cd halt-lab
npm init -y
Create four files:
-
halt.mjs— frozen reasons -
model.mjs— stub first, real model later -
tools.mjs— mocks, including one that wants to spin -
harness.mjs— the loop that must die on purpose
Freeze the reasons before anyone prompts
If a student invents halt_reason: "almost", I treat it as missing. Freeze the enum in week one. Do not “extend it in spirit.”
// halt.mjs
export const HALT_REASONS = Object.freeze([
"completed",
"max_steps",
"wall_clock",
"tool_error",
"schema_fail",
"empty_response",
]);
completed means a final answer that passes your schema. max_steps means the cap won. wall_clock means time died first. The rest are failures. They are not “retry in a trench coat.”
Can your harness produce max_steps on purpose? If not, you do not have a cap. You have a comment.
Lab starter: the loop
This is a starter. It is not a platform. It writes the halt record even when the run is ugly.
// harness.mjs
import { writeFileSync } from "node:fs";
import { HALT_REASONS } from "./halt.mjs";
import { callModel } from "./model.mjs";
import { runTool } from "./tools.mjs";
const MAX_STEPS = 8;
const WALL_CLOCK_MS = 15_000;
function isFinalAnswer(response) {
return response && typeof response.answer === "string" && response.answer.length > 0;
}
export async function runTask(task) {
const started = Date.now();
const record = {
task,
max_steps: MAX_STEPS,
wall_clock_ms: WALL_CLOCK_MS,
steps_used: 0,
tool_calls: [],
halt_reason: null,
answer: null,
};
const messages = [
{
role: "system",
content:
'Solve the task. Call tools only from the allowlist. Final answer must be JSON: {"answer": string}.',
},
{ role: "user", content: task },
];
try {
for (let step = 1; step <= MAX_STEPS; step++) {
if (Date.now() - started > WALL_CLOCK_MS) {
record.halt_reason = "wall_clock";
break;
}
record.steps_used = step;
const response = await callModel(messages);
if (!response) {
record.halt_reason = "empty_response";
break;
}
if (response.tool_call) {
record.tool_calls.push({
step,
name: response.tool_call.name,
args: response.tool_call.args,
});
const result = await runTool(response.tool_call);
messages.push({ role: "assistant", content: JSON.stringify(response) });
messages.push({ role: "tool", content: JSON.stringify(result) });
continue;
}
if (isFinalAnswer(response)) {
record.answer = response.answer;
record.halt_reason = "completed";
break;
}
record.halt_reason = "schema_fail";
break;
}
if (!record.halt_reason) {
record.halt_reason = "max_steps";
}
} catch (err) {
record.halt_reason = "tool_error";
record.error = String(err.message || err);
}
if (!HALT_REASONS.includes(record.halt_reason)) {
record.halt_reason = null;
}
writeFileSync("halt-contract.json", JSON.stringify(record, null, 2));
return record;
}
const task = process.argv[2] || "Find the lab password in the lookup tool.";
runTask(task).then((record) => {
console.log(record.halt_reason, record.steps_used);
});
Notice what I refused to do. I did not let the model vote for “one more try.” I did not catch a timeout and loop until hope appeared. I wrote the file before returning.
If the process dies, I still want that file. Stretch goal: write it on SIGTERM.
A tool that tries to trap you
A lab without a hostile mock is a demo. This lookup always says “try again.” Students love to obey that sentence. Forever.
// tools.mjs
const ALLOWLIST = new Set(["lookup"]);
export async function runTool(call) {
if (!ALLOWLIST.has(call.name)) {
throw new Error(`tool not allowlisted: ${call.name}`);
}
if (call.name === "lookup") {
const delay = Number(process.env.LOOKUP_SLEEP_MS || 0);
if (delay) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
return { ok: true, text: "No exact match. Refine your query and look up again." };
}
throw new Error("unreachable");
}
Start with a stub model that always requests lookup. Checkpoint 3 should be green before anyone touches a real endpoint.
// model.mjs — stub. Swap this after the looping-tool case is boring.
export async function callModel(_messages) {
return { tool_call: { name: "lookup", args: { q: "again" } } };
}
After eight lookups, what is halt_reason? If you said completed, you graded the model’s confidence. I grade the cap.
Checkpoints
Do these in order. The writeup comes last, or it is fan fiction.
-
The file exists. Run
node harness.mjs.halt-contract.jsonmust appear even when the model returns garbage. -
The enum is closed. Print
halt_reason. One of six strings. No synonyms. Nonullafter a normal stop. -
Max steps fires. Keep the looping stub. After
MAX_STEPS, the harness process must exit. The record must saymax_steps. The task is still a fail. That is correct. -
Wall clock fires.
LOOKUP_SLEEP_MS=3000 WALL_CLOCK_MS=2000— patch the constant or read it from env, your choice, but freeze it in the record. You must getwall_clock, not a latecompleted. -
Completed is rare. Only a final
{ "answer": "..." }may usecompleted. A chatty paragraph isschema_fail.
If checkpoint 4 is green on your laptop and red on the server, you measured the wrong clock. Use Date.now() inside the harness. “How long I watched the terminal” is not a timer.
Fair grading rubric
I score the harness, not the model’s IQ. A weak free model should still be able to fail closed. That is the skill.
| Checkpoint | Points | Automatic fail if |
|---|---|---|
| Halt file written | 20 | missing file, invalid JSON |
| Reason in the enum | 20 | extra slogans, null, synonyms |
| Max-steps case | 20 | process still running, or completed on the looping tool |
| Wall-clock case | 20 |
completed after timeout, or no kill |
| Schema gate | 10 | prose accepted as final |
| Writeup quotes the record | 10 | “it worked” with no JSON |
Passing score: 80. You can miss the writeup. You cannot miss the kill switch.
Rules I will not bargain:
- Same
MAX_STEPSandWALL_CLOCK_MSfor every student. No private raises after a friend flakes. - Model quality is not extra credit. A correct halt on a looping tool is full credit for that row.
- If the free server is slower than a laptop, the wall clock still counts. That is shipping, not a bug in the rubric.
- Flakes: run the max-steps case three times. If any run lacks a file, that row is zero. One green run is not a sample size.
Proposed grader — label it proposed, then make it boringly deterministic:
// grade.mjs
import { readFileSync } from "node:fs";
const ENUM = new Set([
"completed",
"max_steps",
"wall_clock",
"tool_error",
"schema_fail",
"empty_response",
]);
export function gradeRecord(path, expect) {
const record = JSON.parse(readFileSync(path, "utf8"));
const fails = [];
if (!ENUM.has(record.halt_reason)) fails.push("reason");
if (expect.reason && record.halt_reason !== expect.reason) fails.push("wrong_reason");
if (expect.maxSteps && record.steps_used > expect.maxSteps) fails.push("over_steps");
if (record.halt_reason === "completed" && typeof record.answer !== "string") {
fails.push("schema");
}
return { ok: fails.length === 0, fails, record };
}
const expect = { reason: process.env.EXPECT_REASON, maxSteps: 8 };
const result = gradeRecord("halt-contract.json", expect);
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
node harness.mjs
EXPECT_REASON=max_steps node grade.mjs
I do not want a screenshot of a chat UI. I want that second command.
Stretch goals
Do not start these until checkpoints 1–5 feel boring.
- Write the record in
finallyand onSIGTERM. - Cap tool time separately from model time. A slow lookup should not steal the whole wall clock with no name.
- Reject unknown tool names before you call the model again. Discovery is not permission.
- Emit
steps_used / max_steps. If it is always1.0, your cap is too tight or your prompts never finish. Say which one in the writeup. - Swap the stub for a real free-model endpoint. The looping-tool case must still halt. If a stronger model “escapes” by inventing a final answer with no tool evidence, that is
schema_failunless lookup actually returned the fact.
Limitations, and who should skip this
This is a bootcamp contract. It is not an agent runtime.
- It does not sandbox the filesystem. A tool that writes under
$HOMEcan still ruin a box. Use a throwaway directory. - It does not prove the answer is true. It proves the loop died for a named reason.
- It does not handle multi-agent graphs, human pauses, or streaming tokens as a heartbeat. A long honest generation can trip
wall_clock. Raise the budget in the spec. Do not delete the clock. - Free model access and a free server are availability options, not a claim about quotas, hardware, model names, or how long anything stays free. Do not write those numbers into the halt record. They are not halt reasons.
- If you are scoring open-ended research agents that should run for hours, this rubric will fail them. Good. Use a different lab.
Who should not use this approach? Anyone grading product quality by “did the blog post get written.” Anyone who cannot freeze the tool allowlist. Anyone whose “agent” is a single function call. You do not need a loop cap for an if-statement. You need fewer slides.
What I want in the writeup
Quote the halt record. Then answer three questions. Short English. No hero narrative.
- Which halt reason did the looping tool produce, and why is that a pass for the harness?
- What happens if you catch
wall_clockand retry? Who pays for that retry? - If you move this harness off your laptop, is the same
WALL_CLOCK_MSstill honest?
If you cannot answer question 3, you built a demo. Bring it back when the loop can die in public.
Steal the rubric. Tighten the enum. Then make the process stop.
Top comments (0)