DEV Community

Charlie Zhu
Charlie Zhu

Posted on

Teaching an Agent Loop to Stop: A 90-Minute Workshop

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", "")
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mickyarun profile image
arun rajkumar

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.