Self-healing code en agentes TypeScript: el bucle que sí corrige
El self-healing code en agentes de TypeScript es un patrón sencillo de describir y fácil de implementar mal. Te cuento primero cómo me enteré.
Un agente mío se pasó tres minutos razonando una tarea, escribió ochenta líneas de TypeScript, llamó a la API interna y devolvió el objeto con userId donde el schema pedía id.
Una palabra.
El pipeline hizo lo que hacen todos: lanzó la excepción, abortó con código de salida 1 y me mandó un aviso para que abriera el editor y cambiara esa palabra a mano.
Lo absurdo es que Zod ya sabía exactamente qué había fallado. Sabía el campo, el tipo recibido, el tipo esperado y la ruta dentro del objeto. Tenía el diagnóstico completo escrito en una estructura de datos. Y con todo eso en la mano, el sistema decidió despertar a un humano.
Así que monté el bucle de autocorrección. Y durante dos semanas no funcionó, gastando el doble de llamadas al modelo, por un motivo que no vi hasta que abrí el objeto de error con el debugger.
Resumen rápido:
- El self-healing convierte el diagnóstico de un verificador determinista en el contexto del siguiente intento, en vez de escalar a un humano.
- Si usas
generateObjectdel Vercel AI SDK, el detalle del fallo no está enerror.message— está enerror.cause. Ese es el error que arruina la mayoría de implementaciones. - Techo de dos intentos totales, y cuenta intentos, no reintentos.
- No todos los errores son curables: los de tipos y schema sí, los de credenciales o herramienta caída no.
Qué es el self-healing code (y qué no es)
El self-healing code es un patrón en el que el sistema que genera —código o datos estructurados— ejecuta un verificador determinista, captura el diagnóstico exacto del fallo y lo reinyecta como contexto en un reintento acotado, en lugar de tratar el fallo como terminal.
La idea de fondo: el validador es el mejor prompt que vas a escribir en tu vida, porque es el único que describe el fallo con precisión de campo y sin ambigüedad.
El bucle tiene cinco pasos:
- Generar. El modelo produce el objeto o el código.
- Verificar. Zod,
tsco el test runner dictaminan. Sin intervención humana y sin LLM de por medio: determinista. - Extraer el diagnóstico real. Campo, ruta, tipo esperado, tipo recibido. Aquí es donde falla casi todo el mundo.
- Reinyectar. El diagnóstico vuelve como turno nuevo de la conversación, junto a la salida anterior.
- Acotar. Techo de intentos y salida limpia cuando se agota.
Conviene separarlo de dos patrones vecinos con los que se confunde.
No es un retry con backoff. El backoff reintenta lo mismo esperando que el mundo cambie: que se descongestione la red, que el proveedor se recupere. El self-healing reintenta algo distinto, porque le has añadido información que antes no estaba. Si reintentas idéntico un fallo de validación, el modelo suele reproducir el mismo error.
No es un circuit breaker. El breaker existe para dejar de insistir cuando una herramienta externa lleva minutos caída; lo conté en circuit breaker para agentes IA. Son capas distintas: el breaker mira la salud de un servicio externo, el self-healing mira la forma de lo que devuelve el modelo. En un agente serio acaban conviviendo.
Y una frontera más: este post va del bucle. De cómo validar y tipar la respuesta en sí ya escribí en cómo tipar las respuestas de una LLM con Zod y TypeScript. Si no tienes esa parte montada, empieza por ahí y vuelve.
El error que hace que tu bucle de autocorrección no sirva de nada
Aquí está lo que me costó dos semanas.
Cuando usas generateObject del Vercel AI SDK y el modelo devuelve algo que no valida, el SDK lanza un NoObjectGeneratedError. La reacción natural es esta:
catch (error: any) {
prompt = `Tu respuesta anterior falló con este error: ${error.message}`;
}
Ese código se ejecuta sin romperse, el bucle gira, gastas otra llamada al modelo y parece que el patrón funciona.
No funciona. El message de un NoObjectGeneratedError es genérico —del tipo "No object generated"— y no lleva el campo, ni el tipo esperado, ni la ruta. Le estás diciendo al modelo "lo has hecho mal" y esperando que adivine el qué.
El detalle está en otras propiedades del error, documentadas en el propio AI SDK:
error.cause— el error subyacente real: elZodErrorcon susissues, o el fallo de parseo de JSON.error.text— el texto crudo que el modelo llegó a generar, que le permite ver su propia salida y compararla con el diagnóstico.error.finishReason— si vale'length', el JSON no es inválido por confusión del modelo: está truncado porque se acabaron los tokens. Reintentar con el mismo límite es tirar dinero; ahí toca subirlo o partir el schema.
Ese último matiz es la diferencia entre un bucle que corrige y un bucle que solo encarece la factura.
Hay un segundo fallo igual de común, y es de contexto. Si en el reintento reasignas el prompt en vez de acumular la conversación, el modelo recibe "tu respuesta anterior falló, corrígela" sin la tarea original y sin su propia salida. generateObject no guarda historial: cada llamada es independiente. El modelo no sabe qué tenía que generar ni qué generó. No hay nada que corregir.
Implementación del bucle en TypeScript
Con eso claro, el bucle queda así. Versiones: AI SDK 5 y Zod 4.
import { generateObject, NoObjectGeneratedError } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const PaymentConfigSchema = z.object({
customerId: z.string().min(5).describe("ID del cliente, prefijo cus_"),
amountInCents: z.number().int().positive().describe("Importe en céntimos, nunca decimal"),
currency: z.enum(["EUR", "USD"]),
maxPaymentRetries: z.number().int().min(1).max(5),
});
type PaymentConfig = z.infer<typeof PaymentConfigSchema>;
// Intentos TOTALES, no reintentos: 2 = la primera llamada y una corrección.
const MAX_ATTEMPTS = 2;
export async function generateSelfHealingConfig(
userRequirement: string,
): Promise<PaymentConfig> {
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
{ role: "user", content: `Genera la configuración de pago para: "${userRequirement}"` },
];
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { object } = await generateObject({
model: anthropic("claude-opus-5"),
schema: PaymentConfigSchema,
messages,
// Clave: el maxRetries del SDK son reintentos de TRANSPORTE (429, 5xx)
// y vale 2 por defecto. Sin ponerlo a 0, cada vuelta de este bucle
// puede disparar hasta 3 peticiones HTTP: 6 llamadas en el peor caso.
maxRetries: 0,
});
return object;
} catch (error) {
if (!NoObjectGeneratedError.isInstance(error)) throw error; // 401, red, bugs propios
// Truncado por tokens: reintentar igual no arregla nada.
if (error.finishReason === "length") {
throw new Error(
"[SELF-HEALING] Respuesta truncada por límite de tokens: súbelo o parte el schema.",
);
}
if (attempt === MAX_ATTEMPTS) {
throw new Error(
`[SELF-HEALING] Sin corregir tras ${MAX_ATTEMPTS} intentos:\n${formatIssues(error.cause)}`,
);
}
// El feedback útil: su salida + el diagnóstico concreto, como turno nuevo.
messages.push({ role: "assistant", content: error.text ?? "(sin salida)" });
messages.push({
role: "user",
content:
`Tu respuesta no cumple el schema:\n${formatIssues(error.cause)}\n\n` +
`Corrige ÚNICAMENTE esos campos y devuelve el objeto completo.`,
});
}
}
throw new Error("[SELF-HEALING] Bucle terminado sin resultado");
}
// El diagnóstico en tres líneas, no el volcado entero.
function formatIssues(cause: unknown): string {
if (cause instanceof z.ZodError) {
return cause.issues
.map((i) => `· ${i.path.join(".") || "(raíz)"}: ${i.message}`)
.join("\n");
}
return cause instanceof Error ? cause.message : String(cause);
}
Tres decisiones que no son cosméticas.
maxRetries: 0. Es la que más gente se salta. El maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte —408, 409, 429 y 5xx— con backoff exponencial. Son compatibles con este bucle, pero se multiplican: dos vueltas tuyas por tres peticiones suyas son seis llamadas donde creías tener dos. Si quieres backoff de red, ponlo tú fuera y controla el total.
El error vuelve como turno de conversación. Al empujar la salida fallida como mensaje assistant y la corrección como user, el modelo ve su propio intento enfrentado al diagnóstico. Reasignar el prompt original pierde ese contraste — es el segundo fallo que veíamos arriba.
formatIssues recorta. Un ZodError serializado entero son cientos de caracteres de ruido que pagas en cada vuelta. Las issues mapeadas a ruta: mensaje son las tres líneas que importan.
Si quieres exprimir la parte del schema —.describe(), uniones discriminadas, enums en vez de strings abiertos— es lo que trabajo en el curso de Zod para TypeScript. Un schema bien diseñado reduce cuántas veces entras en este bucle, que sigue siendo el mejor ahorro disponible.
Las tres reglas para que esto no se convierta en un bucle infinito caro
1. Pásale el diagnóstico, no el volcado
Cinco mil caracteres de stack trace con trazas internas de Node entierran la señal en ruido, y encima los pagas en cada vuelta. Ruta, mensaje y tipo esperado. Nada más.
2. Techo estricto, y cuenta intentos, no esperanzas
Dos intentos totales. Si un modelo actual no arregla un fallo de forma teniendo delante el error exacto, el problema casi nunca es el modelo: es un schema que pide algo que el contexto no contiene. El tercer intento no corrige, factura.
El fallo más común es contar mal. Un for (let i = 1; i <= maxRetries; i++) con maxRetries = 2 da dos intentos totales, es decir, un solo reintento. Si querías dos correcciones, el bucle se te queda corto y no te enteras. Por eso arriba la constante se llama MAX_ATTEMPTS.
Este techo vive dentro del límite global de pasos del agente, no lo sustituye: sobre eso escribí en el agentic loop en producción.
3. Corrección quirúrgica, no regeneración
Pide explícitamente que corrija solo los campos señalados. Si dejas que regenere el objeto entero, es habitual que arregle el campo roto y rompa otro que ya estaba bien — y con techo de dos intentos te quedas sin margen.
Un oráculo por cada tipo de error
Zod valida la forma de los datos en runtime. Es la primera capa, no la única: el patrón es idéntico cambiando quién emite el diagnóstico.
| Oráculo | Qué detecta | Qué le pasas al modelo |
|---|---|---|
| Zod | Salida estructurada que no cumple el schema | issues mapeadas a ruta: mensaje |
tsc --noEmit |
Errores de tipos en el código generado | Código de error, archivo, línea, tipo esperado vs recibido |
| Vitest / Jest | Errores de lógica de negocio | Nombre del test y el diff esperado/recibido |
| ESLint | Estilo y patrones prohibidos | Nada: esto se arregla con --fix, no con el modelo |
El compilador. Cuando el agente escribe código en vez de devolver datos, tsc --noEmit da diagnósticos con archivo, línea y tipos enfrentados. Un Type '{ userId: string }' is not assignable to type '{ id: string }' es la misma señal que un ZodError: precisa, accionable y gratis. Pásale las líneas del diagnóstico, no la salida completa del compilador — en un proyecto mediano son cientos de líneas y un solo error de tipos suele arrastrar diez mensajes derivados del mismo origen.
Los tests. El compilador y Zod atrapan errores de forma; los tests atrapan errores de fondo. Un agente que ejecuta la suite, lee qué aserción falló y corrige antes de enseñarte nada es la versión completa del patrón. Es también donde el techo se vuelve innegociable: un agente iterando contra una suite en rojo sin límite es la forma más rápida que conozco de quemar presupuesto. Y hay una trampa propia de esta capa: ejecuta la suite entera antes de aceptar el parche, no solo el test que fallaba. Arreglar el test A rompiendo el B es un resultado muy común y, si solo miras A, lo das por bueno.
Para montar el entorno donde ese ciclo corre aislado, escribí sobre el test harness para desarrollo con agentes. Y ese salto —de validar datos a montar el ciclo entero de generar, verificar y corregir— es el hilo del curso Construye con IA: de la idea al producto con Claude Code.
Un apunte de arquitectura: el bucle queda más limpio si los fallos ya viajan como datos tipados en lugar de excepciones sueltas, algo que conté en cómo manejar errores en agentes de IA con TypeScript.
Qué errores son curables y cuáles no
Aplicar el bucle a todo es peor que no tenerlo. Esta es la tabla que uso para decidir:
| Tipo de fallo | ¿Self-healing? | Qué hacer |
|---|---|---|
Error de tipos (tsc) |
Sí | Reinyectar diagnóstico, 1 reintento |
| Schema de salida inválido | Sí | Reinyectar error.cause + la tarea original |
| Aserción de test fallida | Sí, con cuidado | Reinyectar el diff y correr la suite completa |
Respuesta truncada (finishReason: 'length') |
No | Subir el límite de salida o partir el schema |
| Lint y formato | No | Determinista: --fix |
| Tool externa 5xx o timeout | No | Circuit breaker, no reintento |
| Credenciales, 401 | No | Abortar y escalar |
| Requisito ambiguo | No | Humano en el bucle |
| Operación con efectos ya aplicados | No | Idempotencia o compensación |
Ese último merece un párrafo. Si el primer intento escribió en base de datos o llamó a un endpoint de cobro, reintentar no es autocorregir: es duplicar. El bucle solo es seguro mientras la operación no haya salido de tu proceso. Valida primero, ejecuta después.
Y hay un coste que conviene tener presente: cada vuelta añade la latencia completa de una llamada al modelo y paga de nuevo los tokens del contexto acumulado, que ahora incluye la salida fallida y el diagnóstico. En un flujo interactivo, a veces es mejor devolver el fallo rápido que hacer esperar el doble para acertar. Si quieres saber en qué se te va de verdad el presupuesto, medir el consumo de tokens del agente es el paso previo.
Cuando el segundo intento también falla
El techo implica que existe un camino de salida, y ese camino no puede ser una excepción sin contexto que alguien encuentre en un log tres días después.
Lo que funciona: registrar el fallo con las cuatro piezas que lo hacen reproducible —la tarea original, la salida del modelo, el diagnóstico del verificador y el número de intentos consumidos— y encolarlo. Ese registro sirve para dos cosas distintas. La inmediata, que alguien lo resuelva. La útil a medio plazo, que la cola se convierte en tu mejor fuente de mejoras del schema: cuando ves tres fallos seguidos sobre el mismo campo, el problema no era el modelo.
Por dónde empezar mañana
Coge el punto de tu agente donde hoy salta una excepción de validación. Uno solo.
Añade tres cosas: extrae el error real (error.cause, no error.message), formatéalo a ruta y mensaje, y devuélvelo como turno nuevo con techo de dos intentos y maxRetries: 0. Loguea cuántas veces entra en la segunda vuelta y cuántas sale con éxito.
Ese ratio es el diagnóstico del diagnóstico. Si entra a menudo y se corrige, tienes un schema mejorable pero un bucle sano. Si entra mucho y no se corrige, tienes un schema imposible: le estás pidiendo al modelo un campo que nadie podría rellenar con el contexto que le das. Y si no entra casi nunca, enhorabuena — tu schema ya hace el trabajo y el bucle es solo la red.
En Dominicode Labs es donde vamos rodando estos patrones sobre proyectos reales, con las métricas puestas.
Los sistemas agénticos que aguantan en producción no son los que no se equivocan. Son los que tienen el diagnóstico a mano y saben devolvérselo al modelo antes de despertar a nadie.
Preguntas frecuentes
¿Por qué mi agente no se corrige aunque le paso el error?
La causa más común es pasar error.message en vez de error.cause. En un NoObjectGeneratedError del Vercel AI SDK, message es un texto genérico que no nombra el campo ni el tipo esperado; el diagnóstico útil vive en error.cause —el ZodError con sus issues— y la salida cruda del modelo en error.text. Con solo message, el bucle gasta llamadas sin darle al modelo nada con lo que corregir.
¿En qué se diferencia el self-healing code de un retry con backoff?
En qué cambia entre un intento y el siguiente. El backoff reintenta la misma petición esperando que se recupere algo externo —red, proveedor, rate limit— y por eso funciona con fallos transitorios. El self-healing modifica la entrada: añade al contexto el diagnóstico que provocó el fallo. Ante un error de schema, el backoff solo repite el mismo error más despacio.
¿No reintenta ya generateObject por su cuenta con maxRetries?
No de esta forma, y conviene ponerlo a 0. La opción maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte: errores de red y respuestas de API reintentables (408, 409, 429, 5xx) con backoff exponencial. Un fallo de validación de schema no entra ahí, se propaga como NoObjectGeneratedError. Si lo dejas por defecto, cada vuelta de tu bucle puede disparar hasta tres peticiones HTTP.
¿Cuántos intentos debería permitir?
Dos totales: la llamada inicial y una corrección. Con el error exacto delante, un modelo actual corrige los fallos de forma en el primer reintento o no los corrige. Un tercero rara vez cambia el resultado y multiplica coste y latencia. Y cuenta intentos, no reintentos: un bucle i <= 2 da una sola corrección, y es donde más gente se equivoca al implementarlo.
¿Se puede hacer self-healing solo con el compilador, sin Zod?
Sí, y son capas complementarias. tsc --noEmit cubre el código que el agente escribe; Zod cubre la salida estructurada que el modelo devuelve. Si tu agente genera archivos, el compilador es tu oráculo principal. Si devuelve objetos que tu aplicación consume, lo es Zod. Muchos agentes acaban usando los dos en puntos distintos del flujo.
¿Sirve para errores de lógica o solo para errores de tipos?
Sirve para los de lógica, pero cambiando el oráculo: ahí el que dictamina es el test runner, y el diagnóstico que reinyectas es la aserción fallida con su diff esperado/recibido. La diferencia práctica es el riesgo. Un error de tipos tiene una única corrección posible; un test rojo admite varias, y alguna rompe otra cosa. Por eso en esta capa se ejecuta la suite completa antes de aceptar el parche.
¿Qué hago si el agente rompe otro test al arreglar el primero?
Tratarlo como un fallo del intento, no como un éxito parcial. Si el criterio de aceptación es solo el test que fallaba, el bucle acepta parches que degradan el código. El criterio tiene que ser la suite entera en verde; si el parche pone A en verde y B en rojo, se descarta y se consume intento. Con techo de dos, eso normalmente significa escalar — que es la respuesta correcta.
¿Es seguro autocorregir una operación que ya escribió en base de datos?
No. Si el intento fallido tuvo efectos externos, el reintento los duplica. El bucle es seguro mientras la operación no haya salido de tu proceso: valida primero, ejecuta después. Si el efecto ya ocurrió, lo que necesitas es idempotencia o compensación, no autocorrección.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
