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.
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:
- Fundamentos — De la teoría al código · Por qué write-test-fix
- Implementación — La spec y los tests · El loop en código · Iteración 1: el
[null, 0, 0]· Iteración 2: el arreglo - Operación — Los tests como condición de parada · Dónde se rompe
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 loop | En el loop write-test-fix |
|---|---|
| Estado | La spec, los tests y el historial de intentos y errores |
| Acción | El modelo escribe (o corrige) el código |
| Observación | La salida del runner de tests |
| Condición de parada | Tests 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 runnerTres 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 modelo | El programa |
|---|---|
| Propone el código | Ejecuta los tests |
| Decide el siguiente intento | Guarda los archivos |
| Lee el feedback del error | Controla 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.tsque 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.