← Todos los posts

El loop más simple que funciona: un agente write-test-fix paso a paso

El agente más simple que sirve: spec y tests a mano, el modelo escribe el código, se ejecuta y el fallo vuelve como contexto. Un loop write-test-fix completo y en código que corre.

Ilustración del loop write-test-fix: un nodo escribe código, otro lo prueba, y el fallo regresa como contexto hasta que los tests pasan

En el post anterior vimos qué es un agent loop: el patrón en abstracto. Ahora vamos a construirlo, en el agente más pequeño que de verdad hace algo útil. Es el loop write-test-fix: tú escribes una especificación y unos tests a mano, el modelo escribe el código, el programa ejecuta los tests, y el error vuelve al modelo hasta que todo pasa. Al final del post tienes el ciclo completo en código que corre.

Resumen para perezosos
  • El loop write-test-fix es el agente mínimo: spec y tests escritos a mano, el modelo genera el código, se ejecuta, y el fallo de los tests vuelve como contexto para el siguiente intento.
  • La condición de parada te la dan los tests: verde y para, rojo y reintenta, hasta un tope de iteraciones. No hace falta que el modelo "decida" que terminó.
  • El feedback es todo. Un test que solo dice "falló" no arregla nada; uno que dice "esperaba [9,5,25] y recibí [null,0,0]" le da al modelo exactamente lo que necesita para corregir.

En este artículo:

De la teoría al código: el agente más simple posible

El post anterior dejó el loop en pseudocódigo: un while con tres componentes —estado, acción y condición de parada— alrededor de un modelo que decide la siguiente acción una y otra vez. Útil para entender el patrón, pero abstracto. Aquí lo vamos a instanciar en el caso más pequeño que sigue siendo un agente de verdad, y al final tienes código que corre.

El mapeo es directo. Cada pieza del loop tiene un equivalente concreto en este ejemplo:

Componente del loopEn el loop write-test-fix
EstadoLa spec, los tests y el historial de intentos y errores
AcciónEl modelo escribe (o corrige) el código
ObservaciónLa salida del runner de tests
Condición de paradaTests en verde, o se alcanza el tope de iteraciones

Si entendiste esa tabla, ya entendiste el post. El resto es verla funcionar.

De esas cuatro piezas, la que más conviene tener en la cabeza es el estado, porque es la que se mueve. No se reinicia en cada vuelta: arranca con la spec y los tests, y va creciendo con cada intento del modelo y cada error del runner. Así se ve esa acumulación a lo largo de las iteraciones:

Iteración 0          Iteración 1               Iteración 2
                     (+ lo que produjo la 1)   (+ lo que produjo la 2)

spec                 spec                      spec
tests                tests                     tests
                     código (intento 1)        código (intento 1)
                     errores del runner        errores del runner
                                               código (intento 2)
                                               errores del runner

Esa acumulación es justo lo que permite que el modelo corrija en lugar de repetir el mismo error: en la iteración 2 ve lo que escribió antes y por qué falló. En este ejemplo el estado crece añadiendo todos los intentos anteriores, sin más. Más adelante en la serie veremos cómo se gestiona cuando el contexto ya no cabe y hay que resumir o descartar.

Por qué write-test-fix es el loop mínimo que sirve

De todas las tareas que se le pueden dar a un agente, escribir código que pase unos tests es la que mejor enseña el patrón, por una razón: la observación es objetiva y gratis. El programa no tiene que pedirle a otro modelo que juzgue si la respuesta es buena; corre los tests y obtiene un sí o un no, con el detalle de qué falló. Esa señal limpia es justo lo que un loop necesita para realimentarse.

A la pieza que decide si la salida es correcta la vamos a llamar el evaluador, y es un término que volverá en la serie. En este ejemplo el evaluador son los tests: un proceso que devuelve verde o rojo. Más adelante veremos evaluadores más complejos —otro modelo que puntúa la respuesta, una comprobación contra datos reales—, pero su función es siempre la misma: convertir la salida del modelo en una señal de la que el loop pueda fiarse.

Compáralo con un agente que redacta un correo. ¿Cómo sabe el loop si el correo está “bien”? No hay un test que devuelva verde. Tendrías que meter un humano o un segundo modelo que evalúe, y ahí la condición de parada se vuelve difusa y discutible. Con código y tests no: la condición de parada es una verdad binaria que el propio programa comprueba.

Por eso este es el loop mínimo que de verdad funciona. El trabajo difícil —decir qué cuenta como “correcto”— lo haces tú una sola vez, por adelantado, al escribir los tests. A partir de ahí el ciclo corre solo:

spec + tests (a mano)


┌──► el modelo escribe el código
│           │
│           ▼
│     ejecutar los tests
│           │
│      ¿todos en verde?
│           │
│     no ───┘   (el error vuelve como contexto)

└── sí ──► entregar el código

El trabajo a mano: la spec y los tests

La parte que hace posible todo lo demás no la escribe el modelo, la escribes tú. Son dos cosas: una especificación de qué función quieres, y unos tests que definen sin ambigüedad qué significa que esté bien. Los tests son la spec ejecutable y, como veremos, también la condición de parada.

El ejemplo es deliberadamente pequeño para que quepa entero en la cabeza. Una función analyze que recibe lecturas numéricas y devuelve tres datos:

Implementa `analyze(readings)` en TypeScript.

`readings` es un arreglo de números. Devuelve un arreglo de tres valores:
[peak, count, total]

- peak:  el valor máximo del arreglo, o null si está vacío.
- count: cuántas lecturas hay.
- total: la suma de todas las lecturas.

Y los tests, escritos a mano. Fíjate en que cada caso imprime qué esperaba y qué recibió: eso es lo que después le va a servir al modelo para corregir.

// analyze.test.ts — escrito a mano. Es la spec ejecutable y la condición de parada.
import { analyze } from "./analyze.js";

function check(name: string, got: unknown, want: unknown): void {
  const g = JSON.stringify(got);
  const w = JSON.stringify(want);
  if (g !== w) {
    console.error(`FAIL ${name}: esperaba ${w}, recibí ${g}`);
    process.exitCode = 1; // salir con código ≠ 0 marca el fallo para el runner
  } else {
    console.log(`ok   ${name}`);
  }
}

check("lecturas normales", analyze([3, 7, 2, 9, 4]), [9, 5, 25]);
check("lista vacía",       analyze([]),               [null, 0, 0]);
check("negativos",         analyze([-1, -5, -2]),     [-1, 3, -8]);
# analyze_test.py — escrito a mano. Es la spec ejecutable y la condición de parada.
import json
import sys
from analyze import analyze

failed = False

def check(name, got, want):
    global failed
    g = json.dumps(got)
    w = json.dumps(want)
    if g != w:
        print(f"FAIL {name}: esperaba {w}, recibí {g}", file=sys.stderr)
        failed = True  # marcar el fallo sin cortar los demás casos
    else:
        print(f"ok   {name}")

check("lecturas normales", analyze([3, 7, 2, 9, 4]), [9, 5, 25])
check("lista vacía",       analyze([]),               [None, 0, 0])
check("negativos",         analyze([-1, -5, -2]),     [-1, 3, -8])

sys.exit(1 if failed else 0)  # código ≠ 0 marca el fallo para el runner
<?php
// analyze_test.php — escrito a mano. Es la spec ejecutable y la condición de parada.
require "analyze.php";

$failed = false;

function check(string $name, mixed $got, mixed $want): void {
    global $failed;
    $g = json_encode($got);
    $w = json_encode($want);
    if ($g !== $w) {
        fwrite(STDERR, "FAIL $name: esperaba $w, recibí $g\n");
        $failed = true; // marcar el fallo sin cortar los demás casos
    } else {
        echo "ok   $name\n";
    }
}

check("lecturas normales", analyze([3, 7, 2, 9, 4]), [9, 5, 25]);
check("lista vacía",       analyze([]),               [null, 0, 0]);
check("negativos",         analyze([-1, -5, -2]),     [-1, 3, -8]);

exit($failed ? 1 : 0); // código ≠ 0 marca el fallo para el runner

Tres casos: el normal, el borde (lista vacía) y uno con negativos para que no se cuele un máximo inicializado en cero. No hace falta más para este ejemplo. La calidad del loop depende por completo de la calidad de estos tests, y a eso vuelvo al final.

El loop en código: generar, ejecutar, realimentar

Con la spec y los tests fijos, el loop es corto. Es el mismo while del post anterior, pero ahora cada línea hace algo real. Lo parto en dos: el ejecutor de tests y el driver del ciclo.

El ejecutor escribe el código que produjo el modelo a un archivo, corre los tests en un proceso aparte y captura la salida. Si el proceso sale con código distinto de cero, hubo un fallo:

// run-tests.ts — ejecuta el código del modelo contra los tests y captura el resultado.
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";

export function runTests(code: string): { passed: boolean; output: string } {
  writeFileSync("analyze.ts", code); // el código que escribió el modelo
  try {
    const output = execSync("node analyze.test.ts", { encoding: "utf8" });
    return { passed: true, output };
  } catch (err: any) {
    // execSync lanza si el proceso sale con código ≠ 0: ahí está el fallo y su detalle.
    return { passed: false, output: (err.stdout ?? "") + (err.stderr ?? "") };
  }
}
# run_tests.py — ejecuta el código del modelo contra los tests y captura el resultado.
import subprocess

def run_tests(code: str) -> dict:
    with open("analyze.py", "w") as f:
        f.write(code)  # el código que escribió el modelo
    proc = subprocess.run(
        ["python", "analyze_test.py"],
        capture_output=True, text=True,
    )
    # returncode ≠ 0 significa que algún test falló: ahí está el detalle.
    return {"passed": proc.returncode == 0, "output": proc.stdout + proc.stderr}
<?php
// run_tests.php — ejecuta el código del modelo contra los tests y captura el resultado.
function run_tests(string $code): array {
    file_put_contents("analyze.php", $code); // el código que escribió el modelo
    $output = [];
    $exitCode = 0;
    // 2>&1 junta stdout y stderr; el código de salida ≠ 0 marca el fallo.
    exec("php analyze_test.php 2>&1", $output, $exitCode);
    return ["passed" => $exitCode === 0, "output" => implode("\n", $output)];
}

El driver es el loop en sí. El estado es el arreglo messages: empieza con la spec y los tests, y crece con cada intento del modelo y cada error del runner.

// run-loop.ts — el agente write-test-fix, completo.
import { readFileSync, writeFileSync } from "node:fs";
import { generateCode } from "./model.js"; // una llamada al LLM que devuelve solo código
import { runTests } from "./run-tests.js";

const MAX_ITER = 5;
const spec = readFileSync("spec.md", "utf8");
const tests = readFileSync("analyze.test.ts", "utf8");

// El estado inicial: el objetivo (spec + tests) es la primera entrada.
const messages = [
  { role: "system", content: "Eres un programador. Devuelve solo el código TS de la función pedida, sin explicaciones ni fences." },
  { role: "user", content: `${spec}\n\nLa función debe pasar estos tests:\n\n${tests}` },
];

for (let i = 1; i <= MAX_ITER; i++) {       // ← tope rígido: nunca un loop sin fin
  const code = await generateCode(messages); // ← el modelo ELIGE qué código escribir
  const result = runTests(code);             // ← el programa EJECUTA los tests

  if (result.passed) {                        // ← condición de parada: todo en verde
    console.log(`✅ Tests en verde en la iteración ${i}`);
    writeFileSync("analyze.ts", code);
    break;
  }

  // El fallo vuelve al estado como nuevo contexto. ESTO es lo que lo hace un loop.
  messages.push({ role: "assistant", content: code });
  messages.push({ role: "user", content: `Los tests fallaron:\n\n${result.output}\n\nCorrige la función.` });
  console.log(`❌ Iteración ${i} falló; reintento con el error como contexto`);
}
# run_loop.py — el agente write-test-fix, completo.
from model import generate_code  # una llamada al LLM que devuelve solo código
from run_tests import run_tests

MAX_ITER = 5
spec = open("spec.md").read()
tests = open("analyze_test.py").read()

# El estado inicial: el objetivo (spec + tests) es la primera entrada.
messages = [
    {"role": "system", "content": "Eres un programador. Devuelve solo el código Python de la función pedida, sin explicaciones ni fences."},
    {"role": "user", "content": f"{spec}\n\nLa función debe pasar estos tests:\n\n{tests}"},
]

for i in range(1, MAX_ITER + 1):           # ← tope rígido: nunca un loop sin fin
    code = generate_code(messages)         # ← el modelo ELIGE qué código escribir
    result = run_tests(code)               # ← el programa EJECUTA los tests

    if result["passed"]:                   # ← condición de parada: todo en verde
        print(f"✅ Tests en verde en la iteración {i}")
        with open("analyze.py", "w") as f:
            f.write(code)
        break

    # El fallo vuelve al estado como nuevo contexto. ESTO es lo que lo hace un loop.
    messages.append({"role": "assistant", "content": code})
    messages.append({"role": "user", "content": f"Los tests fallaron:\n\n{result['output']}\n\nCorrige la función."})
    print(f"❌ Iteración {i} falló; reintento con el error como contexto")
<?php
// run_loop.php — el agente write-test-fix, completo.
require "model.php";      // generate_code(): una llamada al LLM que devuelve solo código
require "run_tests.php";

const MAX_ITER = 5;
$spec = file_get_contents("spec.md");
$tests = file_get_contents("analyze_test.php");

// El estado inicial: el objetivo (spec + tests) es la primera entrada.
$messages = [
    ["role" => "system", "content" => "Eres un programador. Devuelve solo el código PHP de la función pedida, sin explicaciones ni fences."],
    ["role" => "user", "content" => "$spec\n\nLa función debe pasar estos tests:\n\n$tests"],
];

for ($i = 1; $i <= MAX_ITER; $i++) {        // ← tope rígido: nunca un loop sin fin
    $code = generate_code($messages);       // ← el modelo ELIGE qué código escribir
    $result = run_tests($code);             // ← el programa EJECUTA los tests

    if ($result["passed"]) {                 // ← condición de parada: todo en verde
        echo "✅ Tests en verde en la iteración $i\n";
        file_put_contents("analyze.php", $code);
        break;
    }

    // El fallo vuelve al estado como nuevo contexto. ESTO es lo que lo hace un loop.
    $messages[] = ["role" => "assistant", "content" => $code];
    $messages[] = ["role" => "user", "content" => "Los tests fallaron:\n\n{$result['output']}\n\nCorrige la función."];
    echo "❌ Iteración $i falló; reintento con el error como contexto\n";
}

Las cuatro líneas que importan están señaladas: el modelo decide el código, el programa lo ejecuta, comprueba la condición de parada, y si falla, devuelve el error al estado. Idéntico al esqueleto del post anterior; lo único nuevo es que ejecutar ahora corre tests y la observación es texto de fallo concreto. La llamada generateCode es una petición normal al modelo —el patrón de cliente es el mismo que usé en routing de modelos—; aquí lo importante no es el proveedor, es el ciclo a su alrededor.

Fíjate en las dos líneas que actualizan messages cuando algo falla: una guarda el código que escribió el modelo (role: "assistant") y la otra el error del runner (role: "user"). Las dos hacen falta. El error por sí solo no basta: el modelo también necesita ver exactamente qué escribió para saber qué corregir. Sin su propio intento delante, en la siguiente vuelta estaría adivinando otra vez desde cero en lugar de arreglar.

Ese reparto de tareas entre las dos líneas es el mismo de todo el loop, y conviene tenerlo claro porque es una de las ideas que la serie repite: el modelo aporta el juicio, el programa aporta la ejecución y el control.

El modeloEl programa
Propone el códigoEjecuta los tests
Decide el siguiente intentoGuarda los archivos
Lee el feedback del errorControla el loop y cuándo parar

Iteración 1: el [null, 0, 0] que arranca el motor

Primera vuelta. El modelo recibe la spec y los tests, y escribe esto:

// Iteración 1: lo que el modelo escribió en el primer intento.
export function analyze(readings: number[]): [number | null, number, number] {
  // El modelo añadió una guarda para el arreglo vacío... con la comparación equivocada.
  if (readings.length >= 0) return [null, 0, 0];

  let peak: number | null = null;
  let count = 0;
  let total = 0;
  for (const r of readings) {
    peak = peak === null ? r : Math.max(peak, r);
    count += 1;
    total += r;
  }
  return [peak, count, total];
}
# Iteración 1: lo que el modelo escribió en el primer intento.
def analyze(readings):
    # El modelo añadió una guarda para la lista vacía... con la comparación equivocada.
    if len(readings) >= 0:
        return [None, 0, 0]

    peak = None
    count = 0
    total = 0
    for r in readings:
        peak = r if peak is None else max(peak, r)
        count += 1
        total += r
    return [peak, count, total]
<?php
// Iteración 1: lo que el modelo escribió en el primer intento.
function analyze(array $readings): array {
    // El modelo añadió una guarda para el arreglo vacío... con la comparación equivocada.
    if (count($readings) >= 0) return [null, 0, 0];

    $peak = null;
    $count = 0;
    $total = 0;
    foreach ($readings as $r) {
        $peak = $peak === null ? $r : max($peak, $r);
        $count += 1;
        $total += $r;
    }
    return [$peak, $count, $total];
}

La lógica del bucle es correcta. El problema es la guarda de la primera línea: el modelo quiso cubrir el caso de la lista vacía, pero escribió >= 0 en vez de === 0. Y como cualquier arreglo tiene longitud mayor o igual que cero, la función siempre entra por ahí y devuelve el resultado del caso vacío. El bucle nunca se ejecuta. El programa corre los tests y observa esto:

$ node analyze.test.ts
FAIL lecturas normales: esperaba [9,5,25], recibí [null,0,0]
ok   lista vacía
FAIL negativos: esperaba [-1,3,-8], recibí [null,0,0]

Ese [null, 0, 0] es el corazón del post. A primera vista parece solo un fallo, pero es la observación más útil del loop: son los tres acumuladores sin tocar —peak se quedó en null, count y total en 0—, la huella exacta de un bucle que nunca corrió. El test de la lista vacía pasa por casualidad, porque el bug devuelve justo la respuesta del caso vacío para todo. Los otros dos fallan y, al fallar, imprimen el contraste entre lo esperado y lo recibido. Ese texto no se descarta: es lo que vuelve al modelo en la siguiente vuelta.

Iteración 2: el error convertido en feedback

El driver toma la salida del runner y la mete en un mensaje nuevo. El estado que ve el modelo en la segunda vuelta ya no es solo la spec: incluye su propio código y el error que produjo.

Los tests fallaron:

FAIL lecturas normales: esperaba [9,5,25], recibí [null,0,0]
ok   lista vacía
FAIL negativos: esperaba [-1,3,-8], recibí [null,0,0]

Corrige la función.

Con ese contexto, el modelo ya no está adivinando a ciegas. Ve que dos casos devuelven [null, 0, 0] —el resultado del caso vacío— para entradas que no están vacías, y eso apunta directo a la guarda. Corrige la comparación:

// Iteración 2: el modelo vio "recibí [null,0,0]" y arregló la guarda.
export function analyze(readings: number[]): [number | null, number, number] {
  if (readings.length === 0) return [null, 0, 0]; // === en vez de >=
  let peak: number | null = null;
  let count = 0;
  let total = 0;
  for (const r of readings) {
    peak = peak === null ? r : Math.max(peak, r);
    count += 1;
    total += r;
  }
  return [peak, count, total];
}
# Iteración 2: el modelo vio "recibí [None,0,0]" y arregló la guarda.
def analyze(readings):
    if len(readings) == 0:
        return [None, 0, 0]  # == en vez de >=
    peak = None
    count = 0
    total = 0
    for r in readings:
        peak = r if peak is None else max(peak, r)
        count += 1
        total += r
    return [peak, count, total]
<?php
// Iteración 2: el modelo vio "recibí [null,0,0]" y arregló la guarda.
function analyze(array $readings): array {
    if (count($readings) === 0) return [null, 0, 0]; // === en vez de >=
    $peak = null;
    $count = 0;
    $total = 0;
    foreach ($readings as $r) {
        $peak = $peak === null ? $r : max($peak, $r);
        $count += 1;
        $total += $r;
    }
    return [$peak, $count, $total];
}

Un carácter de diferencia. El runner vuelve a correr y esta vez:

$ node analyze.test.ts
ok   lecturas normales
ok   lista vacía
ok   negativos

Verde. La condición de parada se cumple, el loop escribe analyze.ts y termina.

El salto entre la iteración 1 y la 2 no vino de un modelo mejor: vino de que el segundo prompt incluía el error del primero. El mismo modelo, con el fallo delante, corrige lo que a ciegas no podía. Esa realimentación —no la inteligencia del modelo— es lo que hace al agente.

Aquí convergió en dos vueltas porque el bug era simple: una comparación mal escrita que el error señalaba casi directo. No lo tomes como la norma. En producción es habitual que un agente necesite varias iteraciones —arregla un test y rompe otro, o el error tarda en quedar claro—, y a veces no converge en absoluto. Por eso el tope de iteraciones del loop no es decorativo, y por eso más adelante dedico una sección entera a dónde este patrón se rompe.

Por qué los tests son la condición de parada

En el post anterior dije que la condición de parada suele combinar la decisión del modelo con un tope rígido. Este loop es un caso especialmente limpio porque la primera mitad de esa condición no la pone el modelo: la ponen los tests. El agente no para porque “crea” que terminó; para porque una comprobación objetiva pasó a verde.

Eso elimina toda una clase de problemas. Un agente que decide por su cuenta cuándo terminó puede equivocarse: declararse listo con la tarea a medias, o no reconocer nunca que ya acabó. Aquí no hay margen para esa equivocación, porque el criterio vive fuera del modelo, en un proceso que devuelve cero o no-cero.

El tope de iteraciones (MAX_ITER) sigue ahí, y sigue siendo imprescindible. Si el modelo entra en un ciclo donde cada arreglo rompe el anterior, o se topa con un test que no sabe satisfacer, el contador corta el loop pase lo que pase. Las dos mitades cumplen su papel del post anterior: los tests son la autonomía (el agente sabe cuándo cumplió el objetivo) y el tope es el control (pase lo que pase, esto termina).

Dónde se rompe este loop

Si te llevas una sola sección de este post, que sea esta. El ejemplo convergió limpio en dos vueltas, y es fácil terminar de leer con la idea de que esto siempre funciona. No es así. Este loop funciona tan bien como sus tests, y tiene varias formas de fallar que aparecen apenas sales del caso de juguete:

  • Tests débiles, falsa sensación de verde. El agente optimiza para pasar los tests, no para resolver el problema. Si un test es laxo, el modelo puede encontrar un atajo que lo satisface sin hacer lo correcto. La calidad del agente nunca supera la de tus tests.
  • El modelo puede “hacer trampa”. Con tests visibles, nada le impide al modelo escribir código que devuelve los valores esperados a fuerza de casos especiales en lugar de implementar la lógica. Conviene revisar el código final, no solo el verde.
  • Tareas sin verificación objetiva. Todo esto depende de que exista un test binario. Para generar código encaja perfecto; para redactar, diseñar o decidir, no hay un node analyze.test.ts que devuelva la verdad, y este patrón no aplica tal cual.
  • Bucles de corrección que no convergen. A veces el modelo arregla un test y rompe otro, vuelta tras vuelta. Sin el tope de iteraciones, eso es tokens quemados sin avanzar. Con él, al menos para y te avisa.
  • El error tiene que ser legible. Todo el truco depende de que la observación sea útil. Si el runner solo dijera “1 test falló”, el modelo tendría mucho menos con qué trabajar. Por eso los tests imprimen qué esperaban y qué recibieron: el [null, 0, 0] enseña más que un contador de fallos.

Ninguna de estas invalida el patrón; lo acotan. Write-test-fix es la base sobre la que se construyen los agentes de código reales —los que usan herramientas, editan varios archivos y manejan repos enteros—, pero todos, por dentro, siguen corriendo este mismo ciclo de generar, ejecutar y realimentar el error.

Preguntas frecuentes

¿Esto es lo mismo que TDD?

Comparte la idea de escribir los tests antes que el código, pero el actor es distinto. En TDD el desarrollador escribe el test y luego escribe el código que lo pasa. En el loop write-test-fix tú escribes los tests y el modelo escribe el código, en un ciclo automático que se realimenta del fallo. Dicho de otro modo: el loop usa la disciplina de TDD como condición de parada de un agente.

¿El modelo puede hacer trampa para pasar los tests?

Sí, y es un riesgo real. Si los tests son visibles y débiles, el modelo puede escribir código que devuelve justo los valores esperados con casos especiales, sin implementar la lógica general. Las defensas son las de siempre: tests que cubran casos variados (no solo uno feliz), incluir entradas que un atajo no podría adivinar, y revisar el código final en lugar de confiar solo en el verde.

¿Cuántas iteraciones suele necesitar?

Para tareas pequeñas y bien especificadas, pocas: a menudo una o dos, como en el ejemplo. Cuantos más casos borde tengan los tests y más ambigua sea la spec, más vueltas hacen falta. Lo importante no es el número exacto sino tener un tope: sin MAX_ITER, una tarea que el modelo no logra resolver te deja el proceso girando.

¿Se le manda todo el historial al modelo en cada iteración?

En este ejemplo, sí: cada vuelta envía la spec, los tests y todos los intentos y errores anteriores. Es lo más simple y funciona mientras el loop sea corto. El problema aparece cuando son muchas iteraciones: el historial crece hasta no caber en la ventana de contexto, o se vuelve caro. Ahí los sistemas reales recortan —resumen los intentos viejos, se quedan solo con el último error, o guardan parte del estado fuera del prompt—. Es el tema de un post más adelante en la serie; por ahora basta con saber que el estado de este ejemplo crece sin más.

¿Es esto lo que hacen Claude Code, Cursor y agentes parecidos?

Es el núcleo, sí, pero muy reducido. Un agente de código real no se limita a una función: usa herramientas para leer y editar varios archivos, ejecutar comandos, buscar en el repo y leer la salida de la build. La capa de “herramientas” es justo lo que el siguiente post abre. Pero por debajo de toda esa maquinaria sigue latiendo este ciclo: proponer un cambio, ejecutarlo, leer el resultado y corregir.

¿Qué pasa si mis tests están mal?

El agente hereda el error. Si un test exige algo incorrecto, el modelo escribirá código incorrecto que lo satisface, y el loop terminará en verde tan contento. El agente no valida tu spec; la cumple. Por eso los tests escritos a mano son la parte que más cuidado merece: son, literalmente, la definición de “terminado”.

¿Sirve para cualquier lenguaje?

Sí. Nada del loop es específico de TypeScript: solo necesitas un comando que ejecute los tests y devuelva éxito o fallo con detalle. Cambia node analyze.test.ts por pytest, go test o cargo test y el driver es idéntico. La condición de parada es el código de salida del proceso, no el lenguaje.

Conclusión

El loop write-test-fix es la forma más pequeña de ver un agente real funcionando: tú escribes la spec y los tests a mano, el modelo escribe el código, el programa lo ejecuta, y el fallo vuelve como contexto hasta que todo está en verde. La lección que deja es la del primer [null, 0, 0]: el segundo intento no acierta porque el modelo sea más capaz, sino porque el error del primero entró en su contexto. Esa realimentación es el agent loop del post anterior, ya no en pseudocódigo sino en código que corre. Si quieres construirlo, empieza por los tests —son la spec y la condición de parada a la vez—, deja un tope de iteraciones por seguridad, y revisa el código final en lugar de confiar solo en el verde.

En el primer post vimos el patrón en abstracto; en este acabamos de verlo funcionar. Y lo que viene no lo cambia: todo lo que le añadamos a un agente —herramientas, memoria, planificación— seguirá ejecutando exactamente este mismo ciclo de decidir, actuar y observar. El siguiente post abre el componente “acción”: cómo se diseñan las herramientas que convierten este loop mínimo en un agente que puede tocar archivos, comandos y APIs.

Seguir leyendo