DEV Community

Taylor Lin
Taylor Lin

Posted on

Not Every Ticket Wants an Agent: A Glossary and Four-Leaf Decision Tree

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.

  1. Loop type — the control structure that is allowed to call a model: one_shot, pipeline, review, bounded_agent, or no_model.
  2. Side-effect radius — the set of files, hosts, and secrets a loop may touch. Count paths, not vibes. Zero means stdout only.
  3. 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.
  4. Oracle — any non-model check that can reject an output. A failing pytest is an oracle. Another model is not.
  5. Sandbox venue — where the loop executes: local working tree, CI runner, or an ephemeral remote server.
  6. Token sink — a step that burns tokens without shrinking uncertainty. Re-summarizing the same log is a sink.
  7. Observation dependency — true only when the next tool call cannot be named until the previous tool’s output is known.
  8. 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.”

  1. Is the output schema known, and is the side-effect radius at most one file (or stdout)? If yes → Leaf A: one-shot.
  2. Are the steps named in advance, ordered, and independent of live tool output? If yes → Leaf B: pipeline.
  3. Is generation cheap relative to acceptance, and does a human or oracle gate the result? If yes → Leaf C: review loop.
  4. Does the next action depend on a live observation, and can you write a halt predicate today? If yes → Leaf D: bounded agent.
  5. Otherwiseno_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
Enter fullscreen mode Exit fullscreen mode

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

Acceptance is mechanical:

pytest tests/test_refresh.py -q --maxfail=1
git diff --stat -- tests/test_refresh.py
# halt if files changed != 1
Enter fullscreen mode Exit fullscreen mode

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

Numbered execution:

  1. Create a branch. Do not run this on main.
  2. Insert test_empty_cursor that sends cursor= and expects 400 or an empty page, according to the existing API contract. Do not invent a new contract in the patch.
  3. Apply a one-file parser fix.
  4. 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())
Enter fullscreen mode Exit fullscreen mode

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

Numbered runbook:

  1. Write the halt file. If you cannot fill max_steps, you do not have an agent ticket. Return to question 5.
  2. 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.
  3. Allow the agent to observe: run tests, read logs, inspect clock sources.
  4. 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
python -m pytest test_loop_classifier.py -q
Enter fullscreen mode Exit fullscreen mode

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)