A checkout service files a ticket: intermittent 401 on POST /refresh. Someone pastes a HAR file into a chat window. The model rewrites JWT middleware, the session store, and a Redis TTL. Three pull requests later, the failure is still there.
The actual defect is a 30-second clock skew in the test container. Frozen time plus one assertion would have closed the ticket. The expensive mistake is not the model. It is treating every ticket as an agent loop.
This article classifies the loop, not the vendor. The artifact is a glossary, a four-question tree, and one worked example at each leaf. A small Python classifier at the end is meant to be run, not admired.
Why loop type beats model choice
Token spend is a lagging indicator. Uncertainty is the leading one. If the next action does not depend on a fresh observation, an agent is a pipeline wearing a trench coat. If the output schema is already known, even a pipeline is too much machinery.
Free-tier capacity makes the misclassification cheaper, not rarer. The same wrong loop still touches the wrong files. Classify first. Then decide where the loop may run.
Glossary
Use these terms as they are written. If a word is not in this list, do not smuggle it into the halt file.
-
Loop type — the control structure that is allowed to call a model:
one_shot,pipeline,review,bounded_agent, orno_model. - Side-effect radius — the set of files, hosts, and secrets a loop may touch. Count paths, not vibes. Zero means stdout only.
- Halt predicate — a boolean that must become true before the next model call. Tests, typecheckers, diff-stat ceilings, and step counters all count. “Looks good” does not.
- Oracle — any non-model check that can reject an output. A failing pytest is an oracle. Another model is not.
- Sandbox venue — where the loop executes: local working tree, CI runner, or an ephemeral remote server.
- Token sink — a step that burns tokens without shrinking uncertainty. Re-summarizing the same log is a sink.
- Observation dependency — true only when the next tool call cannot be named until the previous tool’s output is known.
- Promotion rule — the documented condition under which a cheaper loop may be upgraded. No promotion rule means no upgrade.
Decision tree
Walk the questions in order. Stop at the first yes. Do not skip to the agent leaf because the ticket mentions “investigate.”
- Is the output schema known, and is the side-effect radius at most one file (or stdout)? If yes → Leaf A: one-shot.
- Are the steps named in advance, ordered, and independent of live tool output? If yes → Leaf B: pipeline.
- Is generation cheap relative to acceptance, and does a human or oracle gate the result? If yes → Leaf C: review loop.
- Does the next action depend on a live observation, and can you write a halt predicate today? If yes → Leaf D: bounded agent.
-
Otherwise →
no_model. Write a script, a failing test, or a clarifying question. Do not spend tokens to postpone that work.
known schema AND radius <= 1? -> one_shot
else steps named AND ordered? -> pipeline
else generate cheap, accept expensive? -> review
else observation-dependent AND halt? -> bounded_agent
else -> no_model
Leaf A — one-shot: frozen clock, one file
The /refresh ticket, after a 10-minute read, has a known shape: tests use datetime.utcnow() against a token exp minted in another process. The output is a single test helper. Radius is one file.
Proposed one-shot prompt (labeled as a prompt, not as a patch you should apply blindly):
Replace datetime.utcnow() in tests/test_refresh.py with a injectable clock.
Do not edit src/. Return a unified diff for that file only.
Acceptance is mechanical:
pytest tests/test_refresh.py -q --maxfail=1
git diff --stat -- tests/test_refresh.py
# halt if files changed != 1
Worked outcome: a 12-line helper, no middleware rewrite. If the diff-stat exceeds one file, the loop type was wrong. Downgrade to no_model and reopen the tree. Do not “just let it finish.”
Leaf B — pipeline: reproduce, then patch, then prove
A second ticket is not intermittent. GET /orders?cursor= returns 500 when cursor is an empty string. The steps are already named: add a regression test, fix the parser, run the suite. Nothing in step 3 depends on improvisation.
# pipeline.yml — proposal, execute only on a branch
steps:
- name: failing_test
run: |
pytest tests/test_orders.py::test_empty_cursor -q || true
- name: patch_parser
run: |
python tools/apply_one_shot.py src/orders/cursor.py
- name: prove
run: |
pytest tests/test_orders.py -q
halt:
max_files: 2
require_oracle: pytest
Numbered execution:
- Create a branch. Do not run this on
main. - Insert
test_empty_cursorthat sendscursor=and expects 400 or an empty page, according to the existing API contract. Do not invent a new contract in the patch. - Apply a one-file parser fix.
- Run the oracle. If pytest still fails, stop. The pipeline does not grow a fourth “think harder” step.
Pipelines fail cleanly. That is the point. An agent that retries the same parser five times is a token sink with extra logging.
Leaf C — review loop: generate cheap, accept expensive
A third ticket asks for a changelog sentence and a VERSION bump. Generation is cheap. Shipping the wrong version is not. The loop is generate-then-gate, not observe-act-observe.
# review_gate.py — runnable check, not an agent
import pathlib, re, subprocess, sys
ALLOWED = {"CHANGELOG.md", "VERSION"}
def changed_files() -> set[str]:
out = subprocess.check_output(["git", "diff", "--name-only", "HEAD"], text=True)
return {line.strip() for line in out.splitlines() if line.strip()}
def main() -> int:
files = changed_files()
extra = files - ALLOWED
if extra:
print("review halt: unexpected files", sorted(extra))
return 2
version = pathlib.Path("VERSION").read_text().strip()
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print("review halt: VERSION is not semver")
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
Worked run: the model drafts CHANGELOG.md. The oracle is python review_gate.py plus a human reading one paragraph. If the model also “helpfully” reformats pyproject.toml, the gate fails. That failure is success for the loop type.
Leaf D — bounded agent: observation-dependent, halt required
A fourth ticket is the original flake, but this time it only reproduces under load inside a Linux container you do not have locally. The next command truly depends on the previous output: run the suite, read the first 401, inspect NTP, then decide whether to freeze time or pin a base image.
That is the only leaf that deserves an agent. It still needs a halt file before the first tool call.
{
"loop_type": "bounded_agent",
"max_steps": 8,
"max_files": 3,
"network": ["pypi.org"],
"halt_any": [
"pytest tests/test_refresh.py exits 0 twice in a row",
"step_count == 8",
"git diff --stat exceeds 3 files"
],
"forbidden": ["~/.ssh", ".env", "prod"]
}
Numbered runbook:
- Write the halt file. If you cannot fill
max_steps, you do not have an agent ticket. Return to question 5. - Choose a sandbox venue that is not your dirty working tree. Local throwaway clones work. CI runners work. An ephemeral remote server works when the flake is environment-specific.
- Allow the agent to observe: run tests, read logs, inspect clock sources.
- Stop on the first halt predicate. Keep the diff only if the oracle is green and the file ceiling holds.
When the venue needs to be isolated from a laptop, a hosted coding-agent environment is in scope. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project that offers free model access and a free server option. Those two availability claims are operator-supplied. This article does not add model names, quotas, hardware specs, or uptime promises.
The product is relevant here for one reason: Leaf D needs a disposable machine and a model behind a halt file. It is not relevant for Leaves A–C. If the ticket already classified as one-shot, using a remote agent server is a category error, not an upgrade.
Reproducible classifier
The tree above is small enough to encode. Save this as loop_classifier.py and run the tests under it. The function is a specification, not a trained router.
from dataclasses import dataclass
from enum import Enum
class LoopType(str, Enum):
ONE_SHOT = "one_shot"
PIPELINE = "pipeline"
REVIEW = "review"
BOUNDED_AGENT = "bounded_agent"
NO_MODEL = "no_model"
@dataclass(frozen=True)
class Ticket:
schema_known: bool
radius: int
steps_named: bool
steps_ordered: bool
generate_cheap: bool
accept_expensive: bool
observation_dependent: bool
halt_predicate_ready: bool
def classify(t: Ticket) -> LoopType:
if t.schema_known and t.radius <= 1:
return LoopType.ONE_SHOT
if t.steps_named and t.steps_ordered:
return LoopType.PIPELINE
if t.generate_cheap and t.accept_expensive:
return LoopType.REVIEW
if t.observation_dependent and t.halt_predicate_ready:
return LoopType.BOUNDED_AGENT
return LoopType.NO_MODEL
# test_loop_classifier.py
from loop_classifier import Ticket, LoopType, classify
def test_refresh_helper_is_one_shot():
t = Ticket(True, 1, False, False, True, True, False, False)
assert classify(t) is LoopType.ONE_SHOT
def test_empty_cursor_is_pipeline():
t = Ticket(False, 2, True, True, True, True, False, True)
assert classify(t) is LoopType.PIPELINE
def test_changelog_is_review():
t = Ticket(False, 2, False, False, True, True, False, True)
assert classify(t) is LoopType.REVIEW
def test_container_flake_is_bounded_agent():
t = Ticket(False, 3, False, False, False, True, True, True)
assert classify(t) is LoopType.BOUNDED_AGENT
def test_no_halt_means_no_model():
t = Ticket(False, 9, False, False, False, True, True, False)
assert classify(t) is LoopType.NO_MODEL
python -m pytest test_loop_classifier.py -q
Decision table for the same five tickets:
| Ticket | schema | radius | named steps | obs-dep | halt | Leaf |
|---|---|---|---|---|---|---|
| Frozen clock helper | yes | 1 | no | no | n/a | one_shot |
| Empty cursor 500 | no | 2 | yes | no | pytest | pipeline |
| Changelog + VERSION | no | 2 | no | no | gate script | review |
| Load-only 401 flake | no | 3 | no | yes | 8 steps | bounded_agent |
| “Investigate auth” | no | ? | no | maybe | missing | no_model |
If your real ticket cannot fill the columns, the classification is no_model. Missing data is not a license to start an agent.
Limitations, and who should not use this
The classifier ignores latency, team politics, and model quality. It will send a well-specified refactor to pipeline even when the codebase has no tests. That is correct as far as loop type goes, and still unsafe as a shipping process. Add an oracle or do not generate.
Do not use Leaf D on repositories that contain production credentials, customer PII, or signing keys. A free remote server is a shared failure domain until you prove otherwise. Do not use the tree as a reason to skip code review. Do not use it when the halt predicate is “the agent will know.”
Teams without a test runner should stay on Leaves A–C at most, and should prefer no_model for anything with radius greater than one. Researchers exploring an unknown protocol may need a human-driven lab notebook, not a bounded agent with max_steps: 8.
The free model access and free server option do not change those constraints. They only change the cost of running Leaf D after the halt file exists.
If a ticket already classifies as a bounded agent, and the halt file is filled in, running that loop on an isolated free server is a reasonable experiment. Classify the loop first. The server is the last decision, not the first.
Top comments (0)