Skip to content
KoishiAI
ไทย
← Contents

Chapter 5 of 12 · Agentic Engineering — A Practitioner Playbook for Production Software with AI Agents

Loop, Gate, and Red / Green

The gate is the heart of the system. Make it weak and the agent learns to turn it green without making the program correct. A complete verify.sh, a script that catches gate-cheating, the red-green order that must be enforced, and a loop driver with a ceiling and stall detection.

The gate is the heart of the whole system. Make it weak and the agent learns to turn it green without making the program correct. Make it too strict or too slow and the loop never closes, and people stop using it.

5.1 What makes a good gate

PropertyWhat it means in practice
DeterministicTen runs give the same result. No test depends on time, network, or ordering
Fast enoughThe quick set under 60 seconds, the full set under 10 minutes. Slower than that and the loop breaks
LoudThe error says what is wrong, where, what was expected and what was received — not merely exit 1
LayeredSplit into stages that stop on the first failure rather than waiting for later ones
Tamper-evidentThe gate files are outside what the agent may edit, and CI runs the same set as local

5.2 verify.sh — the definition of “passing”

#!/usr/bin/env bash
# THE GATE — the single source of truth for "the work is done"
# The agent may not edit this file (enforced by a PreToolUse hook and CODEOWNERS)
set -uo pipefail

FAILED=0
STAGE_LOG=$(mktemp)

run_stage () {
  local name="$1"; shift
  printf "\n\033[1m▸ %s\033[0m\n" "$name"
  local start=$SECONDS
  if "$@" 2>&1 | tee -a "$STAGE_LOG"; then
    printf "  \033[32mPASS\033[0m (%ss)\n" "$((SECONDS-start))"
  else
    printf "  \033[31mFAIL\033[0m (%ss)\n" "$((SECONDS-start))"
    FAILED=1
    [ "${FAIL_FAST:-1}" = "1" ] && finish
  fi
}

finish () {
  echo
  if [ $FAILED -eq 0 ]; then
    echo "════ ALL GATES PASSED ════"; exit 0
  else
    echo "════ GATE FAILED ════"; exit 1
  fi
}

# ── STAGE 1: shape (fastest, catch it first) ─────────────
run_stage "format"      uv run ruff format --check .
run_stage "lint"        uv run ruff check .
run_stage "types"       uv run mypy src --strict

# ── STAGE 2: logical correctness ─────────────────────────
run_stage "unit"        uv run pytest tests/unit -q --timeout=30
run_stage "property"    uv run pytest tests/property -q --hypothesis-seed=0

# ── STAGE 3: assembly ────────────────────────────────────
run_stage "integration" uv run pytest tests/integration -q --timeout=120
run_stage "contract"    uv run schemathesis run openapi.json --checks all

# ── STAGE 4: structural quality ──────────────────────────
run_stage "coverage"    uv run pytest --cov=src --cov-fail-under=80 -q
run_stage "arch-rules"  uv run pytest tests/architecture -q     # dependency rules
run_stage "security"    uv run bandit -r src -q -ll
run_stage "deps-audit"  uv run pip-audit --strict
run_stage "migrations"  ./scripts/check_migrations.sh

# ── STAGE 5: anti-cheating ───────────────────────────────
run_stage "no-cheating" ./scripts/check_no_cheating.sh

finish

Checking that nothing was made green by silencing the tools:

#!/usr/bin/env bash
set -uo pipefail

BASE="${BASE_REF:-origin/main}"
DIFF=$(git diff "$BASE"...HEAD -- 'src/**' 'tests/**')
BAD=0

check () {
  local pattern="$1" msg="$2"
  # Only look at lines that were ADDED in the diff
  if echo "$DIFF" | grep -E "^\+" | grep -Eq "$pattern"; then
    echo "  ✗ $msg"; BAD=1
  fi
}

check '@pytest\.mark\.(skip|xfail)' "skip/xfail added to a test"
check '#\s*type:\s*ignore'          "type: ignore added"
check '#\s*noqa'                    "noqa added"
check 'assert True'                 "an assertion that tests nothing"
check 'except\s*:\s*pass'           "an exception silently swallowed"

# The test count must not go down
OLD=$(git grep -c "^def test_" "$BASE" -- tests | awk -F: '{s+=$3} END {print s+0}')
NEW=$(git grep -c "^def test_" HEAD -- tests | awk -F: '{s+=$3} END {print s+0}')
if [ "$NEW" -lt "$OLD" ]; then
  echo "  ✗ test count fell from $OLD to $NEW"; BAD=1
fi

[ $BAD -eq 0 ] && echo "  no signs of gate evasion"
exit $BAD

Why a “no-cheating” stage is necessary

Put an agent under the condition “make the tests pass” and it will find the shortest route — and sometimes the shortest route is making the tests disappear. This is not model dishonesty; it is what happens when a goal is measured by a proxy. The remedy is to make the proxy hard to game, not to ask nicely for it not to be gamed.

5.3 Red / Green that an agent can genuinely do

TDD works better with an agent than with a human in one respect: a red test is machine-readable feedback, which is exactly what an agent wants most. But the order has to be enforced, or the agent writes the implementation first and then tests that confirm what it just wrote — which is worthless.

[RED-1]      Write tests from the spec only. Do not read or write the implementation
[RED-2]      Run them → they must be red, and red for the right reason
             (AssertionError = good | ImportError/NameError = not yet a usable red)
[RED-3]      Show the red output, with the number of failing tests
[GREEN]      Write the smallest implementation that turns them green.
             Add no feature the tests did not ask for
[REFACTOR]   Restructure with the tests green throughout, re-running after each change

A prompt for driving red-green:

Task: implement docs/specs/coupon.md

Follow this order strictly, and report every step with real output:

Step 1 (RED)
- Read docs/specs/coupon.md only. Do not open src/domain/coupon.py
- Write tests in tests/unit/test_coupon.py covering every rule in the spec,
  including every case the spec says must be rejected
- Run `uv run pytest tests/unit/test_coupon.py -q` and paste the output
- Confirm each failure is an AssertionError or missing business logic.
  If it fails with ImportError, create a stub raising NotImplementedError and re-run
- Stop here and wait for me to confirm the tests are sufficient

Step 2 (GREEN) — begin only when I say "go"
- Write src/domain/coupon.py until the tests are green
- Do not edit the test file in this step, not one line
- If you believe a test is wrong, stop and explain. Do not fix it yourself
- Run verify-fast.sh after every change

Step 3 (REFACTOR)
- Improve readability, running the tests after every change
- Report a summary diff of what was restructured and why

The technique: stopping between RED and GREEN

The highest-value moment for a human is between RED and GREEN. Reading the tests the agent wrote takes three to five minutes and determines the quality of the entire piece of work, because the implementation will always run toward the tests. If the tests are wrong, the code will be wrong with them, and no gate will catch it.

5.4 Controlling the loop: ceilings and stall detection

#!/usr/bin/env bash
# Headless loop driver — works with claude -p, codex exec, or cursor-agent
set -uo pipefail

TASK="${1:?usage: ./agent-loop.sh 'task description'}"
MAX_ITER="${MAX_ITER:-6}"
STALL_LIMIT=2          # stop if the failing-test count does not fall for N rounds

prev_fail=999999
stall=0

for i in $(seq 1 "$MAX_ITER"); do
  echo "═══ round $i / $MAX_ITER ═══"

  # --- 1. one round of agent work ---
  claude -p "$TASK
Context: read AGENTS.md, memory/pitfalls.md, and the previous round's output in /tmp/gate.log
Take one step that brings verify closer to green, then stop. Do not try to fix everything at once." \
    --allowedTools "Read,Edit,Write,Bash(uv run pytest:*),Bash(uv run ruff:*)" \
    --output-format stream-json > "/tmp/agent_$i.log"

  # --- 2. run the gate ---
  ./scripts/verify-fast.sh > /tmp/gate.log 2>&1
  if [ $? -eq 0 ]; then
    echo "GREEN at round $i"
    ./scripts/verify.sh                  # full set, to confirm
    exit $?
  fi

  # --- 3. is it making progress? ---
  cur_fail=$(grep -oE '[0-9]+ failed' /tmp/gate.log | head -1 | grep -oE '[0-9]+' || echo 999999)
  echo "  still failing: $cur_fail (previous round: $prev_fail)"

  if [ "$cur_fail" -ge "$prev_fail" ]; then
    stall=$((stall+1))
    if [ "$stall" -ge "$STALL_LIMIT" ]; then
      echo "stopping: no progress for $STALL_LIMIT rounds — the spec is probably unclear, or the task too large"
      git stash push -m "agent-stalled-$(date +%s)"   # keep it for a human, do not discard
      exit 3
    fi
  else
    stall=0
  fi
  prev_fail=$cur_fail

  # --- 4. safety: the code should not balloon ---
  ADDED=$(git diff --numstat | awk '{s+=$1} END {print s+0}')
  if [ "$ADDED" -gt 800 ]; then
    echo "stopping: $ADDED lines added, more than one PR should contain"
    exit 4
  fi
done

echo "stopping: $MAX_ITER rounds and still not green"
echo "A human should read /tmp/gate.log and decide whether to split the task or fix the spec"
exit 2

Every stop condition worth having

ConditionSignalWhat to do next
SuccessThe whole gate is greenOpen the PR
Ceiling reachedN rounds, still redA human reads the log and splits the task
No progressFailure count flat for two roundsThe spec is probably ambiguous — go back and fix it
Ballooning codeDiff over 800 linesToo large for one loop; break it up
Touching forbidden thingsThe hook blocked three or more timesThe agent has the boundaries wrong — fix AGENTS.md
Going in circlesThe diff repeats a previous roundStuck in a local minimum; reset and change approach
Budget exhaustedTokens or time over the limitStop and report cost against result

A rule from experience

If the agent cannot get it passing within three rounds, the problem is usually not the agent — it is an ambiguous spec or a task that is too large. Raising the ceiling to 15 rounds almost never helps; it only makes the code messier. The correct fix is to go back and fix the spec, not to extend the ceiling.

What this chapter settles

The gate defines “done”, so it must be deterministic, fast enough, articulate, layered, and beyond the agent’s reach. It needs a stage dedicated to catching evasion, because a goal measured by a proxy will always attract shortcuts. Enforce red before green, always. And when a loop stops making progress, stop it rather than raising its ceiling.

The next chapter covers the layers of testing, both meanings of A/B, and how to measure whether you have hurt your users yet.