The first time this exercise ran, two students watched their agent rewrite settings.py twelve times in ninety seconds. Every edit looked defensible on its own. The ledger told a different story: the same twelve-character fingerprint appeared on every pass, meaning the model had found a loop and moved in. Nobody had taught the loop when to quit.
That gap is the whole workshop. Recent DEV threads argued that most agents are thin control flow wearing a costume (one example) and that loop engineering has predictable failure modes (another). Both are useful framing, and neither is the point here. The point is that stop conditions can be tested like anything else, and a class of twelve people can prove it in ninety minutes without a single network call.
What the ninety minutes buy
The session is built for pairs, one laptop per pair, Python 3.11 or newer, git, and a terminal. No cloud account is required for the first sixty minutes. The schedule is deliberately front-loaded, because the failure is easier to show than to explain.
| Minutes | Block | Artifact produced |
|---|---|---|
| 0–10 | Reproduce a runaway loop | One ledger file with a repeated fingerprint |
| 10–35 | Read the guard, run four tests | A green suite and a named stop_reason per test |
| 35–60 | Add a fifth stop condition | A new reason string plus its test |
| 60–80 | Swap the scripted proposer for a live model | One real run, one real ledger line |
| 80–90 | Pair review of ledgers | A written exit ticket |
The teaching sequence matters more than the timing. Students see a loop burn budget first, instrument it second, and only then connect it to a hosted model. Reversing those steps turns the exercise into a configuration tutorial, which is a different and much weaker class.
The artifact: a loop with testable exits
Everything below runs offline. The scripted propose function stands in for a model so the tests stay deterministic.
# loop_guard.py
"""A bounded agent loop with testable stop conditions. No network required."""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
MAX_STEPS = 6
MAX_SECONDS = 20.0
LEDGER = Path("ledger.jsonl")
def fingerprint(action: str, payload: str) -> str:
blob = f"{action}\x00{payload}".encode("utf-8")
return hashlib.sha256(blob).hexdigest()[:12]
def write_ledger(row: dict) -> None:
with LEDGER.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, sort_keys=True) + "\n")
def _stop(n: int, action: str, payload: str, reason: str, elapsed: float) -> dict:
row = {
"step": n,
"action": action,
"fp": fingerprint(action, payload),
"stop_reason": reason,
"seconds": round(elapsed, 3),
}
write_ledger(row)
return row
def run(task, propose, apply, max_steps=MAX_STEPS, max_seconds=MAX_SECONDS) -> dict:
"""propose(task, n) -> (action, payload); apply(action, payload) -> None."""
seen: set[str] = set()
started = time.monotonic()
step = 0
for step in range(1, max_steps + 1):
elapsed = time.monotonic() - started
if elapsed > max_seconds:
return _stop(step, "halt", "", "budget:wallclock", elapsed)
action, payload = propose(task, step)
fp = fingerprint(action, payload)
if fp in seen:
return _stop(step, action, payload, "loop:repeat", elapsed)
seen.add(fp)
if action == "done":
return _stop(step, action, payload, "goal:reached", elapsed)
apply(action, payload)
return _stop(step, "halt", "", "budget:steps", time.monotonic() - started)
Four exit paths, four names: goal:reached, loop:repeat, budget:steps, budget:wallclock. Students are asked to say out loud which one they expect before each test runs. The habit of predicting the exit is the actual lesson.
# test_loop_guard.py
import loop_guard as lg
def _script(seq):
def propose(task, n):
return seq[min(n - 1, len(seq) - 1)]
return propose
def test_goal_wins_before_budget():
row = lg.run("t", _script([("done", "")]), lambda a, p: None, max_steps=5)
assert row["stop_reason"] == "goal:reached", row
def test_repeat_is_caught():
row = lg.run("t", _script([("edit", "a")]), lambda a, p: None, max_steps=5)
assert row["stop_reason"] == "loop:repeat", row
def test_step_budget():
seq = [("edit", "a"), ("edit", "b"), ("edit", "c")]
row = lg.run("t", _script(seq), lambda a, p: None, max_steps=3)
assert row["stop_reason"] == "budget:steps", row
def test_wallclock_budget():
row = lg.run("t", _script([("edit", "a")]), lambda a, p: None,
max_steps=5, max_seconds=-1)
assert row["stop_reason"] == "budget:wallclock", row
Run python -m pytest -q test_loop_guard.py and the suite reports four passed. Delete ledger.jsonl between runs, or point LEDGER at a temp path, because rows append and old lines look like fresh failures to a reader in a hurry.
The fifth exercise is open-ended. Common additions include a token-delta ceiling, a diff-size cap, and a stop when two consecutive fingerprints share a prefix. Each addition needs its own test; an untested stop reason is a comment with extra steps.
Swapping in a live model
The adapter below is a sketch, not a tested client. It assumes the access you were given speaks an OpenAI-compatible chat endpoint. Adapt the parsing to whatever shape you actually receive.
# adapter_sketch.py — shape only, adapt before use
import json
import os
import urllib.request
ENDPOINT = os.environ["AGENT_ENDPOINT"]
KEY = os.environ["AGENT_KEY"]
def propose(task, n):
body = {
"model": os.environ["AGENT_MODEL"],
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"{task}\nstep {n}"},
],
"temperature": 0,
}
req = urllib.request.Request(
ENDPOINT,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {KEY}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
reply = json.load(resp)["choices"][0]["message"]["content"]
return parse_action(reply) # ("edit", "path::patch") or ("done", "")
For classes that need a model but not a setup lecture, MonkeyCode's free model access fits this step cleanly, and its free server option covers students whose machines cannot run the harness or who join remotely. The operator states a free allowance on the order of ten million tokens as of September 2026. Treat that number the way the workshop treats every allowance: read the current terms on the project page before scheduling a session around it, and never write a quota into a stop condition. Step and time budgets stay stable when a pricing page does not.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Local runner or hosted runner
| Situation | Local | Hosted free server |
|---|---|---|
| Deterministic tests, no network | Yes | Unnecessary |
| Live model calls from a shared laptop | Possible | Reasonable |
| Regulated or personal data | Only here | Avoid without a written agreement |
| Long jobs needing durable queues | Neither | Neither |
Two limits belong on the whiteboard before anyone leaves. The harness proves a loop stops; it says nothing about whether the work is correct, so a green suite is not a quality signal. And the wall-clock check is cooperative — it fires between steps, so a hung call inside propose still needs a process-level timeout and supervisor. The flat JSONL ledger tolerates one writer; concurrent runs interleave and a compliance reviewer will not accept it as an audit log.
Teams with data-residency requirements, anyone building durable job infrastructure, and anyone who needs the ledger to be evidence should skip this approach rather than stretch it. Everyone else leaves with a file that names why their agent quit, a test that proves the name, and one habit that survives the workshop: predict the exit before you run the loop.
If the exercise sounds useful, port loop_guard.py into your own repository and bring a fifth stop_reason to the next session.
Top comments (2)
Teaching the failure before the instrumentation is the right order, and most workshops get it backwards. The repeating twelve-character fingerprint is a good artifact because students can see it before they understand it.
One exit I'd offer for the fifth-condition slot, from a domain where the loop costs money: effect:already-applied. Your four all reason about the agent's own trajectory — goal reached, repeat detected, budget spent, steps exhausted. None asks whether the world already has the change. An agent retrying a write after a timeout isn't looping by any of those measures. It's making progress by its own ledger while duplicating an effect outside it.
It's also the condition that forces students to notice the ledger records what the agent did, not what happened.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.