Tag: Agentes IA

  • Desplegar agentes LangChain en producción sin perder el estado

    Desplegar agentes LangChain en producción sin perder el estado

    En local funcionaba perfecto.

    El agente respondía, llamaba a sus herramientas, escribía token a token en la terminal. Lo metí en un contenedor y lo subí. A los pocos días empecé a ver el mismo patrón en los logs: conversaciones cortadas a mitad, y usuarios que volvían y encontraban un agente sin memoria de nada.

    No había ningún error en el código del agente. El agente estaba bien. Lo que estaba mal era todo lo que hay entre el agente y el usuario.

    Y es que desplegar agentes LangChain en producción no se parece a desplegar una API REST. Una API REST responde en 200 milisegundos y no recuerda nada. Un agente tarda treinta segundos, mantiene la conexión abierta todo ese rato, guarda estado entre turnos y llama a servicios externos que fallan. Cuatro propiedades que rompen, una por una, las suposiciones sobre las que está construida tu infraestructura.

    Si todavía estás decidiendo la forma del agente —grafo de estados o bucle— eso lo desarrollé en LangGraph TypeScript: cuándo un grafo gana al while loop. Este post empieza donde acaba aquel: ya tienes el grafo, ahora hay que sacarlo del portátil.

    Todo el código está escrito contra langchain 1.5 y @langchain/langgraph 1.4, con @langchain/langgraph-checkpoint-postgres 1.0. Es importante que mires las versiones: la API de creación de agentes y la de streaming cambiaron en LangChain 1, y casi todos los tutoriales que vas a encontrar están escritos contra la anterior.

    Última revisión: 31 de agosto de 2026. Si LangGraph publica una 2.x, el PostgresSaver es lo primero que hay que volver a comprobar.


    Los 3 fallos al desplegar agentes LangChain en producción

    Los tres fallos que rompen un agente en producción son la conexión que corta el proxy, el estado que vive en RAM y la herramienta sin timeout. No son los que parecen, y son los que me han costado tiempo de verdad:

    # Fallo Por qué pasa
    1 La conexión se corta a mitad de respuesta El proxy cierra la conexión por inactividad: mientras el modelo "piensa" no viajan bytes
    2 El agente pierde la memoria El historial vivía en RAM y el contenedor se reinició o escaló a otra instancia
    3 Una herramienta se cuelga y arrastra al proceso Sin timeout ni cancelación, la petición queda colgada y la conexión SSE ocupando memoria

    Conviene desmontar un mito antes de seguir, porque lo he leído muchas veces: el bucle del agente no bloquea el event loop. El trabajo de un agente es esperar respuestas HTTP del modelo y de sus herramientas, así que es I/O, y Node o Bun siguen atendiendo peticiones mientras tanto. Lo que sí se te agota es otra cosa. La memoria que ocupa cada conexión abierta, el límite de concurrencia de tu plataforma, y los sockets que nadie cerró porque el cliente se fue sin avisar.


    El estado: sácalo de la RAM el primer día

    El estado de un agente LangGraph no puede vivir en una variable del proceso: en cuanto el contenedor se reinicia o escala, la conversación desaparece. Este es el arreglo con más retorno y el más barato de aplicar.

    Mientras el estado vive en memoria, tu agente recuerda hasta el próximo despliegue. Y como los reinicios no los decides tú —los decide el autoescalado, un health check o un deploy—, no es un riesgo teórico: pasa.

    La solución en LangGraph es un checkpointer, que guarda el estado del grafo después de cada paso en una base de datos externa:

    import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
    
    const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL!);
    
    // Solo la primera vez: crea las tablas que necesita el checkpointer.
    await checkpointer.setup();
    

    Ese setup() va en el paso de migraciones de tu despliegue, no en el arranque de cada instancia. Si lo dejas en el boot y levantas diez réplicas, tienes diez procesos creando las mismas tablas a la vez.

    A partir de ahí, cada conversación se identifica con un thread_id. El agente no "recuerda" nada en memoria: al recibir un turno nuevo, lee el estado de ese hilo desde Postgres, avanza y vuelve a escribirlo.

    Eso cambia una propiedad importante de tu servicio: pasa a ser reemplazable. Puedes matar el contenedor, desplegar una versión nueva o levantar diez réplicas detrás de un balanceador, y cualquiera de ellas puede continuar cualquier conversación, porque el estado no está en ninguna de ellas.


    El servidor: streaming que sobrevive al proxy

    El segundo problema es la conexión. Un agente tarda decenas de segundos en completar una respuesta, y durante buena parte de ese tiempo no manda ni un byte, porque está esperando al modelo o ejecutando una herramienta.

    Para un proxy —Nginx, Cloudflare, el balanceador de tu PaaS— una conexión abierta que no transmite nada es una conexión muerta, y la cierra.

    Así que hay tres cosas que hacer, y las tres se olvidan:

    • Enviar las cabeceras SSE inmediatamente, para que el proxy sepa que esto es un stream y no espere a tener el cuerpo entero.
    • Mandar un latido cada pocos segundos aunque no haya nada que decir, para que la conexión nunca esté inactiva.
    • Abortar el trabajo si el cliente se va, o seguirás pagando tokens de una respuesta que ya no lee nadie.
    import express from "express";
    import { createAgent } from "langchain";
    
    // Necesita @langchain/anthropic instalado y ANTHROPIC_API_KEY en el entorno.
    const agent = createAgent({
      model: "anthropic:claude-sonnet-5",
      tools: [buscarPedido], // la definimos más abajo
      checkpointer,          // el PostgresSaver de arriba
    });
    
    const app = express();
    app.use(express.json());
    
    app.post("/api/agent/chat", async (req, res) => {
      // El thread_id se valida contra el usuario autenticado: si no,
      // cualquiera puede leer la conversación de cualquier otro.
      const { threadId, message } = req.body;
    
      res.setHeader("Content-Type", "text/event-stream");
      res.setHeader("Cache-Control", "no-cache, no-transform");
      res.setHeader("Connection", "keep-alive");
      res.setHeader("X-Accel-Buffering", "no"); // que Nginx no acumule el stream
      res.flushHeaders();                       // sin esto, el proxy espera
    
      // Latido: mantiene viva la conexión frente al idle timeout del proxy.
      const heartbeat = setInterval(() => {
        if (res.writableEnded || res.destroyed) return;
        res.write(": ping\n\n");
      }, 15_000);
    
      // Si el cliente cierra la pestaña, se cancela el trabajo del agente.
      const controller = new AbortController();
      res.on("close", () => {
        clearInterval(heartbeat);
        controller.abort();
      });
    
      try {
        const stream = await agent.streamEvents(
          { messages: [{ role: "user", content: message }] },
          {
            version: "v3",
            configurable: { thread_id: threadId },
            signal: controller.signal,
          },
        );
    
        await Promise.all([
          (async () => {
            for await (const m of stream.messages) {
              for await (const token of m.text) {
                res.write(`data: ${JSON.stringify({ type: "token", text: token })}\n\n`);
              }
            }
          })(),
          (async () => {
            for await (const call of stream.toolCalls) {
              res.write(`data: ${JSON.stringify({ type: "tool", name: call.name })}\n\n`);
            }
          })(),
        ]);
    
        res.write("data: [DONE]\n\n");
      } catch (err) {
        if (!controller.signal.aborted) {
          res.write(`data: ${JSON.stringify({ type: "error" })}\n\n`);
        }
      } finally {
        clearInterval(heartbeat);
        res.end();
      }
    });
    
    // Cloud Run y casi cualquier PaaS inyectan PORT: no lo fijes a mano.
    app.listen(process.env.PORT ?? 3000);
    

    Dos detalles que merecen su párrafo.

    El version: "v3". Es la API de streaming con proyecciones tipadas, y aparece en langchain a partir de la 1.4.0. En vez de recibir un chorro plano de eventos y filtrar por nombre, iteras stream.messages para los tokens y stream.toolCalls para las herramientas, cada uno por su lado. Si copias un tutorial que usa version: "v2" y compara event.event === "on_chat_model_stream", estás escribiendo contra la API anterior.

    Un aviso que no vas a encontrar en esos tutoriales: LangChain la marca como experimental en su propia definición de tipos —"This v3 stream is experimental and its API may change in future releases"—. La uso igualmente porque la alternativa envejece peor, pero fija la versión en tu package.json y no la des por estable.

    El signal. RunnableConfig acepta un AbortSignal, y es lo que convierte el res.on("close") en una cancelación real en lugar de un simple return. Sin él, el cliente se va pero tu servidor sigue generando tokens contra la API del modelo hasta el final.

    Si vienes del stack de Vercel, el mismo problema con otras piezas lo resolví en streaming de respuestas de IA con NestJS y el Vercel AI SDK.


    Las herramientas: donde se cuelga todo

    El fallo que más veces he tenido que diagnosticar en producción no está en el modelo ni en el grafo. Está en una herramienta que llama a una API de terceros que ese día tarda cuarenta segundos en responder.

    Sin timeout propio, esa herramienta se lleva por delante la petición entera. El usuario ve un cursor parpadeando, la conexión sigue abierta consumiendo memoria, y tú no sabes en qué paso se quedó.

    La regla es simple: toda herramienta que salga a la red lleva su propio timeout, más corto que el de la petición completa, y devuelve un texto en lugar de reventar. Ésta es la buscarPedido que usa el agente de arriba:

    import { tool } from "langchain";
    import * as z from "zod";
    
    const buscarPedido = tool(
      async ({ id }) => {
        try {
          const res = await fetch(`${API}/pedidos/${id}`, {
            signal: AbortSignal.timeout(8_000), // esta tool falla en 8s o no falla
          });
          return JSON.stringify(await res.json());
        } catch {
          // El agente lee esto y decide: reintentar o admitir que no puede.
          return "El servicio de pedidos no respondió en 8 segundos.";
        }
      },
      {
        name: "buscar_pedido",
        description: "Busca un pedido por su identificador",
        schema: z.object({ id: z.string() }),
      },
    );
    

    Y que falle está bien. Un error controlado vuelve al agente como resultado de la herramienta, el modelo lo lee y puede reintentar o decir que no ha podido. Una herramienta colgada, en cambio, no le da ninguna información con la que trabajar: el agente se queda esperando y el usuario también.

    Si además quieres que la herramienta muera cuando el cliente cierra la pestaña, combina su propio timeout con el signal que le llega en el config: el AbortSignal.timeout por sí solo no escucha esa cancelación.

    Ese diseño de herramientas —contrato claro, fallo rápido y un error que el modelo pueda leer— es el que trabajo paso a paso en el curso Construye con IA con Claude Code.

    Cómo evitar que ese reintento se convierta en un bucle sin fin lo desarrollé en Agentic Loop en TypeScript. Y cómo probar todo esto en CI antes de que llegue a producción, en test harness para agentes de IA.


    Qué pasa de verdad cuando el contenedor se reinicia

    Aquí es donde casi todas las guías te dicen una verdad a medias. "Con un checkpointer no pierdes el estado" es cierto, pero conviene saber exactamente qué se salva y qué no.

    Si el contenedor muere mientras un agente está a mitad de una tarea:

    • Se conserva todo lo que ya estaba confirmado en el último checkpoint: los turnos anteriores, los resultados de las herramientas que ya terminaron y el estado del grafo hasta ese punto.
    • Se pierde el paso en vuelo. Los tokens que se estaban generando en ese momento no están en ninguna parte, y la conexión SSE del cliente se cae con el proceso.
    • No se reanuda solo. No hay nadie que retome la tarea al arrancar el contenedor nuevo. Y ojo con lo que significa "volver a llamar". El checkpoint se escribe por paso del grafo. Si el proceso murió justo después de que el modelo pidiera una herramienta, el estado guardado termina en un mensaje del asistente con tool_calls y ninguna respuesta. Mandar ahí un mensaje nuevo del usuario produce un 400 del proveedor, porque todo tool_use exige su tool_result. Antes de aceptar el turno siguiente hay que cerrar el paso pendiente de ese hilo.

    Esto tiene una consecuencia de diseño que hay que asumir pronto: el thread_id tiene que sobrevivir al navegador y estar atado al usuario. Que lo genere el cliente está bien; que el servidor se lo crea sin comprobar contra quién ha iniciado sesión, no. Y si el identificador solo vive en la memoria del navegador, un refresco lo pierde y la conversación se queda huérfana en la base de datos: existe, pero nadie sabe pedirla.

    Y si la tarea es larga de verdad —un informe que tarda diez minutos, un procesamiento por lotes—, el patrón correcto no es este. Es aceptar la petición, devolver un identificador y ejecutar el trabajo en una cola aparte, con el cliente consultando el progreso. Un agente detrás de una petición HTTP tiene sentido para conversación, no para trabajo de fondo.


    Empaquetar y desplegar agentes LangChain en producción

    Empaquetar un agente es un Dockerfile normal con un detalle que rompe builds: desde Bun 1.2 el lockfile por defecto es bun.lock, no bun.lockb.

    FROM oven/bun:1-alpine
    WORKDIR /app
    
    # Desde Bun 1.2 el lockfile por defecto es bun.lock (texto), no bun.lockb.
    COPY package.json bun.lock ./
    RUN bun install --frozen-lockfile --production
    
    COPY . .
    
    ENV NODE_ENV=production
    USER bun
    CMD ["bun", "run", "src/server.ts"]
    

    Si copias un Dockerfile de hace un par de años vas a ver COPY package.json bun.lockb ./, y con un proyecto actual esa línea falla porque ese archivo ya no existe.

    Y un .dockerignore al lado, que es el otro detalle que rompe builds:

    node_modules
    .git
    .env*
    

    Sin él, el COPY . . te mete el node_modules de tu portátil encima del que acabas de instalar dentro del contenedor, con binarios compilados para otra plataforma.

    Sobre dónde desplegarlo, lo único que importa de verdad es cuánto tiempo te dejan tener una conexión abierta:

    Plataforma Timeout por defecto Máximo Qué tienes que tocar
    Cloud Run 300 s (5 min) 3.600 s (60 min) Subir el timeout y fijar una instancia mínima para no pagar arranque en frío por conversación
    Render · Railway · Fly Idle timeout propio, más corto No es ilimitado El latido SSE: sin él la conexión cuenta como inactiva y la cortan

    Los números de Cloud Run salen de su documentación de timeouts. Para un agente conversacional con streaming, el valor de fábrica se queda corto en cuanto una herramienta se ralentiza.

    Y aquí hay una distinción que cuesta un incidente aprender: el latido no te salva del timeout de Cloud Run. El latido derrota los timeouts de inactividad, que es lo que aplican los PaaS. El de Cloud Run es duración máxima de la petición, y corta igual aunque estés emitiendo tokens sin parar. En todos los que he probado, además, ninguno mantiene una conexión abierta indefinidamente.

    Un agente en producción además habla con servicios externos, y ahí el problema deja de ser el deploy y pasa a ser el transporte y la autenticación. Eso lo cubrí en MCP en producción: lo que se rompe cuando tu server sale del portátil.


    No despliegues a ciegas

    En un backend clásico te basta con los errores HTTP. En un agente necesitas ver el árbol de decisiones: qué prompt se envió, qué herramienta se ejecutó, cuánto tardó y qué costó. Sin eso, "va lento" y "responde mal" son incidencias que no puedes investigar.

    No lo desarrollo aquí porque ya tiene su sitio. El planteamiento está en cómo monitorear agentes de IA en producción, la implementación en Langfuse paso a paso, y la parte que te va a llegar en la factura, en medir el consumo de tokens.


    Checklist antes de pulsar deploy

    1. El estado, fuera del proceso. Checkpointer con setup() ejecutado y thread_id generado y persistido por el cliente.
    2. El stream, blindado. flushHeaders(), latido cada 15 segundos y AbortSignal conectado al cierre de la conexión.
    3. Las herramientas, con timeout propio. Más corto que el de la petición, y que fallen con un error que el agente pueda leer.
    4. El timeout de la plataforma, subido. El de fábrica está pensado para APIs que responden rápido, no para agentes.
    5. Trazas desde el primer despliegue. No desde el primer incidente.

    Las arquitecturas de agentes que tengo funcionando, con sus fallos y lo que costó arreglarlos, las comparto cada semana en Dominicode Labs.

    Que un agente funcione en tu portátil es un experimento. Que sobreviva a un reinicio es ingeniería.


    Preguntas frecuentes

    ¿Cómo se despliega un agente LangChain en producción?

    Desplegar agentes LangChain en producción son cuatro decisiones, no una. Primera: sacar el estado del proceso con un checkpointer persistente —PostgresSaver sobre Postgres— para que cualquier réplica pueda continuar cualquier conversación. Segunda: servir la respuesta por SSE con flushHeaders(), un latido cada 15 segundos y un AbortSignal atado al cierre del cliente, para que ningún proxy corte el stream. Tercera: poner timeout propio a cada herramienta que salga a la red, más corto que el de la petición. Y cuarta: subir el timeout de la plataforma, que de fábrica está pensado para APIs que responden en milisegundos. El contenedor en sí es lo de menos.

    ¿Postgres o Redis para el checkpointer?

    Postgres por defecto. El estado de una conversación es un dato que quieres conservar, consultar y auditar más tarde, y Postgres te lo da sin trabajo extra. Redis tiene sentido cuando la latencia de lectura del estado empieza a notarse de verdad o cuando el historial es efímero y no te importa perderlo. Empezar por Redis "porque es más rápido" suele salir caro el día que necesitas saber qué le contestó el agente a un cliente hace tres semanas.

    Si el contenedor se reinicia a mitad de una tarea, ¿se reanuda sola?

    No. Se conserva el estado hasta el último checkpoint confirmado, pero el paso que estaba en vuelo se pierde y nadie retoma la tarea por su cuenta. La reanudación la dispara el cliente cuando vuelve a llamar con el mismo thread_id, siempre que el paso pendiente se cierre antes de mandar un mensaje nuevo. Si el hilo se quedó con una petición de herramienta sin responder, el proveedor devuelve un 400. Y si necesitas que el trabajo termine sí o sí aunque nadie esté mirando, eso no va en una petición HTTP: va en una cola.

    ¿SSE o WebSocket para un agente?

    SSE en la mayoría de casos. La comunicación de un agente conversacional es casi toda en un sentido —el servidor manda tokens— y SSE va sobre HTTP normal, así que atraviesa proxies y balanceadores sin configuración especial. La reconexión automática te la da EventSource, pero solo habla GET: con el endpoint POST de arriba consumes el stream con fetch y ReadableStream, y la reconexión la escribes tú. WebSocket compensa cuando de verdad necesitas un canal bidireccional con mucho tráfico del cliente hacia el servidor, y a cambio te complica el despliegue.

    ¿Cuánto timeout pongo en Cloud Run?

    El valor de fábrica son 5 minutos y el máximo son 60. Para un agente conversacional, subirlo a 10-15 minutos suele ser suficiente: cubre las respuestas largas y las herramientas lentas sin dejar conexiones zombis eternas. Ponerlo al máximo no es gratis, porque una conexión colgada ocupa una instancia durante todo ese tiempo.

    ¿Esto vale con otro modelo que no sea Claude?

    Sí. La arquitectura —checkpointer externo, streaming con latido, cancelación y timeouts por herramienta— es independiente del proveedor. Lo único que cambia es el identificador del modelo que le pasas a createAgent y el paquete de integración correspondiente.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Circuit breaker para agentes IA: la tool cae y el modelo inventa

    Circuit breaker para agentes IA: la tool cae y el modelo inventa

    Un martes por la tarde, la API de búsqueda de un cliente empezó a devolver 500. Un despliegue suyo mal hecho: tres minutos de caída.

    El agente que consumía esa API estuvo cuarenta minutos haciendo tonterías caras.

    Primero reintentó. Normal. Luego, al ver que la herramienta seguía fallando, hizo lo que hacen los modelos cuando se les cierra una puerta: buscar otra. Llamó a una tool que no tocaba, cambió los parámetros "por si acaso" y en el paso 14 se inventó tres productos con sus precios.

    Faltaba un circuit breaker para agentes IA. El patrón es viejo — Michael Nygard lo describió en Release It! en 2007 para microservicios y Martin Fowler lo popularizó después — pero cuando en medio del reintento hay un LLM, cambia una pieza fundamental. Y esa pieza es la que casi nadie implementa.


    Qué es un circuit breaker para agentes IA

    Un circuit breaker para agentes IA es una máquina de estados que envuelve la ejecución de cada tool: cuenta los fallos de infraestructura dentro de una ventana de tiempo y, al superar un umbral, deja de llamar a la API y devuelve al modelo un resultado estructurado que le dice que esa herramienta no está disponible y qué debe hacer en su lugar.

    La diferencia con el circuit breaker clásico de microservicios está en quién recibe el corte. Allí el consumidor es código, que obedece un 503 y ejecuta su rama de fallback. Aquí el consumidor es un LLM, que interpreta el error y decide por su cuenta. Y si no se lo dices tú, lo que decide es reintentar o inventarse el dato.


    Los tres estados del circuit breaker en un agente

    El breaker es una máquina de estados que envuelve la ejecución de una herramienta.

    CLOSED. Todo pasa. Vas contando fallos en una ventana de tiempo. Si en los últimos 60 segundos hay 4 fallos de infraestructura, abres.

    OPEN. Rechazas sin llamar a la API. Esto es lo importante: el execute de la tool ni siquiera hace fetch. Devuelve en microsegundos. No hay timeout de 30 segundos, no hay latencia, no hay una API agonizante recibiendo más carga de la que ya no puede atender.

    HALF_OPEN. Pasado el tiempo de reset, dejas pasar una sola llamada de prueba. Si funciona, vuelves a CLOSED. Si falla, vuelves a OPEN y el contador de espera empieza otra vez. Ojo con esto en un agente: si dejas pasar todas las llamadas de un turno en half-open, el modelo puede lanzar tres tool calls en paralelo y le acabas metiendo tres peticiones a un servicio que se está levantando.

    Hasta aquí es idéntico a un microservicio. La diferencia empieza en lo que devuelves cuando el circuito está abierto.


    Qué devolver al modelo cuando el circuito está abierto

    Cuando un servicio A tiene el circuito abierto contra el servicio B, devuelve un 503 y quien lo consume es código. El código no negocia: ve el 503 y ejecuta la rama de fallback que escribiste.

    En un agente, quien recibe la respuesta de la tool es un modelo de lenguaje. Y un modelo de lenguaje sí negocia.

    Si le devuelves esto:

    { "error": "request failed" }
    

    El modelo va a reintentar. No porque sea tonto, sino porque no tiene ninguna forma de saber que existe un circuito y que está abierto. Desde su punto de vista una llamada ha fallado, y lo razonable ante una llamada que falla es intentarlo otra vez, quizá con otros parámetros.

    Has puesto un breaker que ahorra la petición HTTP pero no ahorra ni una sola iteración del loop ni un solo token. El agente sigue quemando pasos hasta agotar el presupuesto que le pusiste en stopWhen — si es que se lo pusiste, que de eso hablo en el post del agentic loop.

    El resultado de una tool es un canal de comunicación con el modelo. Es prompt. Úsalo como tal.

    {
      "ok": false,
      "toolUnavailable": true,
      "retryAfterSeconds": 27,
      "instruction": "La herramienta \"searchCatalog\" está fuera de servicio por fallos repetidos del proveedor. No vuelvas a llamarla durante los próximos 27 segundos: cualquier intento se rechazará sin llegar a la API. Usa \"searchCatalogSnapshot\" (catálogo cacheado de hace unas horas) y avisa en tu respuesta final de que los precios pueden estar desactualizados. Si el usuario pedía stock en tiempo real, dile que ese dato no está disponible ahora. No lo estimes ni lo inventes."
    }
    

    Cuatro cosas, y las cuatro hacen falta:

    1. Qué herramienta está caída, por su nombre exacto — el mismo que ve en la definición de tools.
    2. Cuánto tiempo, en segundos concretos. Un "temporalmente" no le dice nada.
    3. La prohibición explícita de reintentar, con el motivo: no es que vaya a fallar, es que ni siquiera va a salir de tu servidor.
    4. Qué hacer en su lugar, en concreto. Y la orden de no inventarse lo que la API le habría dado, que es exactamente lo que hizo el agente de mi cliente en el paso 14.

    Y la alternativa que le ofreces tiene que existir de verdad en el toolset. Mandar al modelo a una herramienta que no le has dado es pedirle justo lo que intentas evitar: que se la invente.

    Añade también una línea al system prompt explicando el protocolo: "si una tool devuelve toolUnavailable: true, esa herramienta no está disponible en este turno; sigue las instrucciones del campo instruction y no la vuelvas a llamar". El modelo cumple bastante bien cuando la instrucción es específica y llega en el sitio donde toma la decisión.


    Qué errores abren el circuito de una tool (y cuáles no)

    Aquí es donde la mayoría de implementaciones se rompen, y se rompen hacia el lado peligroso: abriendo el circuito de una API que funciona perfectamente.

    Los 5xx cuentan. Los timeouts cuentan. Los errores de red cuentan. Los 429 cuentan también, porque cuando un servicio te dice que vas demasiado rápido, lo correcto es dejar de llamarlo un rato.

    Los 4xx de validación no cuentan nunca. Si el modelo manda { query: 42 } donde había que mandar un string, la API devuelve un 400 y eso no significa que la API esté rota. Significa que el modelo la está llamando mal. Si sumas ese 400 al contador, un modelo torpe con los argumentos te abre el circuito de un servicio sano — y a partir de ahí has convertido un problema de prompt en una caída de herramienta.

    Distinguir "la herramienta está rota" de "el modelo la está llamando mal" es la diferencia entre un breaker que te salva y uno que sabotea al agente.

    Error ¿Cuenta para abrir? Por qué
    5xx Sí El servicio está roto
    429 Sí Saturado: lo correcto es dejar de llamarlo un rato
    Timeout / AbortError Sí Sin timeout no hay fallo que contar, solo un agente esperando
    ECONNREFUSED, ECONNRESET, ENOTFOUND Sí La API no está ahí
    400, 422 Nunca El modelo mandó argumentos mal formados
    404 Nunca El recurso no existe; la API respondió bien
    409 Nunca Conflicto de estado, no caída
    Error desconocido No Ante la duda no penalizas: un falso positivo tumba una herramienta sana
    // tool-errors.ts
    export class ToolHttpError extends Error {
      constructor(readonly status: number, message: string) {
        super(message);
        this.name = "ToolHttpError";
      }
    }
    
    const NETWORK_ERRORS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed/i;
    
    export function isInfrastructureFailure(error: unknown): boolean {
      if (error instanceof ToolHttpError) {
        // 5xx: el servicio está roto. 429: saturado, y lo correcto es dejar de llamar.
        // 400, 404, 409, 422: los argumentos venían mal. Eso es el modelo, no la API.
        return error.status >= 500 || error.status === 429;
      }
    
      // AbortSignal.timeout() lanza un AbortError / TimeoutError
      if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
        return true;
      }
    
      if (error instanceof Error) {
        // Ojo con el runtime: en Bun el código de red viaja en error.cause.code,
        // no en el mensaje. Mirar solo message deja pasar un ECONNREFUSED.
        const code = (error as { cause?: { code?: string } }).cause?.code;
        if (code && NETWORK_ERRORS.test(code)) return true;
        return NETWORK_ERRORS.test(error.message);
      }
    
      // Ante la duda, no penalizas: un falso positivo tumba una herramienta sana
      return false;
    }
    

    La política de "ante la duda no cuenta" es deliberada. Un breaker que no abre cuando debía te cuesta unos reintentos. Un breaker que abre cuando no debía te deja al agente sin una herramienta buena durante medio minuto, y el modelo se pone creativo.

    La mitad de estos 4xx los evitas antes de que ocurran con schemas estrictos en la definición de la tool. Es el mismo trabajo de blindaje que vemos en el curso de Zod para TypeScript: si el argumento no valida, ni siquiera llega a salir una petición.


    Cómo implementar un circuit breaker en TypeScript

    Factory con estado en cierre, sin dependencias. Umbral de fallos, ventana deslizante, timeout de reset y una única prueba en half-open.

    // circuit-breaker.ts
    export type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN";
    
    export class CircuitOpenError extends Error {
      constructor(readonly toolName: string, readonly retryAfterMs: number) {
        super(`Circuito abierto para la herramienta "${toolName}"`);
        this.name = "CircuitOpenError";
      }
    }
    
    export interface BreakerOptions {
      name: string;
      failureThreshold?: number;
      windowMs?: number;
      resetTimeoutMs?: number;
      isFailure?: (error: unknown) => boolean;
      onStateChange?: (from: BreakerState, to: BreakerState) => void;
    }
    
    export function createCircuitBreaker({
      name,
      failureThreshold = 4,
      windowMs = 60_000,
      resetTimeoutMs = 30_000,
      isFailure = () => true, // ¡ojo! sobrescríbelo siempre con isInfrastructureFailure
      onStateChange = () => {},
    }: BreakerOptions) {
      let state: BreakerState = "CLOSED";
      let failures: number[] = [];
      let openedAt = 0;
      let probeInFlight = false;
    
      const transition = (next: BreakerState) => {
        if (next === state) return;
        onStateChange(state, next);
        state = next;
      };
    
      const currentState = (now: number): BreakerState => {
        if (state === "OPEN" && now - openedAt >= resetTimeoutMs) {
          transition("HALF_OPEN");
        }
        return state;
      };
    
      return {
        name,
        // getState() no es puro: dispara la transición OPEN -> HALF_OPEN. Si lo
        // polleas desde un exportador de métricas, la transición la provoca la
        // observabilidad y no el tráfico real.
        getState: () => currentState(Date.now()),
        getRetryAfterMs: () => Math.max(0, resetTimeoutMs - (Date.now() - openedAt)),
    
        async execute<T>(fn: () => Promise<T>): Promise<T> {
          const now = Date.now();
          const phase = currentState(now);
    
          if (phase === "OPEN") {
            throw new CircuitOpenError(name, resetTimeoutMs - (now - openedAt));
          }
    
          // En half-open solo pasa una petición: las demás siguen rechazadas
          if (phase === "HALF_OPEN" && probeInFlight) {
            // Espera corta a propósito: si la prueba en vuelo cierra el circuito, no
            // quieres haberle dicho al modelo que abandone la tool medio minuto
            throw new CircuitOpenError(name, 1_000);
          }
          if (phase === "HALF_OPEN") probeInFlight = true;
    
          try {
            const result = await fn();
            if (phase === "HALF_OPEN") {
              probeInFlight = false;
              failures = [];
              transition("CLOSED");
            }
            return result;
          } catch (error) {
            if (!isFailure(error)) {
              // No es culpa de la herramienta: no toca el contador
              if (phase === "HALF_OPEN") probeInFlight = false;
              throw error;
            }
    
            const failedAt = Date.now();
            failures = failures.filter((t) => failedAt - t < windowMs); // ventana deslizante
            failures.push(failedAt);
    
            if (phase === "HALF_OPEN" || failures.length >= failureThreshold) {
              openedAt = failedAt;
              probeInFlight = false;
              failures = [];
              transition("OPEN");
            }
            throw error;
          }
        },
      };
    }
    
    export type CircuitBreaker = ReturnType<typeof createCircuitBreaker>;
    

    Un fallo en half-open reabre directamente, sin esperar a acumular el umbral. Es intencionado: si la prueba falla, el servicio sigue caído y no hay nada que discutir.

    Ahora el wrapper que convierte la excepción en un resultado que el modelo entiende, integrado con la definición de tools del Vercel AI SDK:

    // with-breaker.ts
    import { tool } from "ai";
    import { z } from "zod";
    import { createCircuitBreaker, CircuitOpenError, type CircuitBreaker } from "./circuit-breaker";
    import { ToolHttpError, isInfrastructureFailure } from "./tool-errors";
    
    interface UnavailableInfo {
      toolName: string;
      retryAfterSeconds: number;
    }
    
    export function withBreaker<TArgs, TResult>(
      breaker: CircuitBreaker,
      onOpen: (info: UnavailableInfo) => Record<string, unknown>,
      execute: (args: TArgs) => Promise<TResult>,
    ) {
      return async (args: TArgs) => {
        try {
          return { ok: true, data: await breaker.execute(() => execute(args)) };
        } catch (error) {
          if (error instanceof CircuitOpenError) {
            return onOpen({
              toolName: error.toolName,
              retryAfterSeconds: Math.max(1, Math.ceil(error.retryAfterMs / 1000)),
            });
          }
          // Este fallo puede ser justo el que acaba de abrir el circuito: el modelo
          // tiene que enterarse ahora, no en la siguiente iteración
          if (breaker.getState() === "OPEN") {
            return onOpen({
              toolName: breaker.name,
              retryAfterSeconds: Math.max(1, Math.ceil(breaker.getRetryAfterMs() / 1000)),
            });
          }
    
          // Fallo puntual con el circuito cerrado: el modelo aún puede reintentar,
          // pero necesita saber qué falló para no repetir la misma llamada
          return { ok: false, error: error instanceof Error ? error.message : "Error desconocido" };
        }
      };
    }
    
    const searchBreaker = createCircuitBreaker({
      name: "searchCatalog",
      failureThreshold: 4,
      windowMs: 60_000,
      resetTimeoutMs: 30_000,
      isFailure: isInfrastructureFailure,
    });
    
    export const searchCatalog = tool({
      description: "Busca productos en el catálogo en tiempo real",
      inputSchema: z.object({ query: z.string().min(2) }),
      execute: withBreaker(
        searchBreaker,
        ({ toolName, retryAfterSeconds }) => ({
          ok: false,
          toolUnavailable: true,
          retryAfterSeconds,
          instruction:
            `La herramienta "${toolName}" está fuera de servicio por fallos repetidos del proveedor. ` +
            `No vuelvas a llamarla durante los próximos ${retryAfterSeconds} segundos: cualquier ` +
            `intento se rechazará sin llegar a la API. Usa "searchCatalogSnapshot" y avisa en tu ` +
            `respuesta final de que los precios pueden estar desactualizados. Si el usuario pedía ` +
            `stock en tiempo real, dile que ese dato no está disponible ahora. No lo inventes.`,
        }),
        async ({ query }: { query: string }) => {
          const res = await fetch(`${process.env.CATALOG_API}/search?q=${encodeURIComponent(query)}`, {
            signal: AbortSignal.timeout(4_000),
          });
          if (!res.ok) throw new ToolHttpError(res.status, `Búsqueda falló con ${res.status}`);
          return res.json();
        },
      ),
    });
    

    Fíjate en el segundo if del catch: el fallo que abre el circuito también tiene que hablarle al modelo. Si esperas a la siguiente llamada para avisarle, has regalado una iteración entera del loop justo en el peor momento, el momento en que acabas de decidir que la herramienta está muerta.

    El ejemplo va sobre el AI SDK de Vercel 7, donde el schema de la tool se declara en inputSchema. Ese nombre existe desde la 5: si sigues en la 4.x el campo se llama parameters y el resto del wrapper no cambia.

    Fíjate también en el AbortSignal.timeout(4_000). Sin timeout explícito no hay breaker que valga: una petición colgada no genera un fallo que contar, genera un agente esperando. El timeout es lo que convierte "lento" en "fallido", y sin eso el patrón entero no arranca. Es el tipo de detalle que trato en programación defensiva en TypeScript.


    Un breaker por herramienta, nunca uno global

    Si la API de búsqueda está caída, la base de datos sigue respondiendo perfectamente. Un breaker global convierte un fallo parcial en una caída total del agente: pierdes cuatro herramientas sanas por culpa de una rota.

    Un registro por nombre de tool y listo:

    const breakers = new Map<string, CircuitBreaker>();
    
    export const breakerFor = (name: string, options: Partial<BreakerOptions> = {}): CircuitBreaker => {
      const existing = breakers.get(name);
      if (existing) return existing;
    
      const created = createCircuitBreaker({ name, isFailure: isInfrastructureFailure, ...options });
      breakers.set(name, created);
      return created;
    };
    

    Y una advertencia que cuesta una tarde de depuración: el estado del breaker tiene que vivir fuera de la petición. Si creas el breaker dentro del handler del chat, cada conversación arranca con el contador a cero y el patrón no protege absolutamente nada. Ámbito de módulo como mínimo. Si corres en serverless con varias instancias, el estado compartido va a Redis o cada instancia aprenderá por su cuenta que la API está caída — y pagarás el aprendizaje N veces.

    Y los umbrales no son iguales para todas: una API de pagos crítica aguanta 6 fallos antes de abrir, un scraper de enriquecimiento prescindible abre a los 2.


    El fallback: qué le das al modelo cuando no hay datos

    Tienes tres opciones, y elegir mal aquí desperdicia el breaker.

    Respuesta cacheada. El último snapshot bueno. Sirve para catálogos, listados y configuración. Obligatorio decirle al modelo que los datos son viejos y de cuándo son, para que lo declare en su respuesta.

    Herramienta degradada. Búsqueda local en vez de búsqueda semántica remota. Peor resultado, cero dependencia externa.

    Seguir sin el dato, declarándolo. La opción más honesta y la más infravalorada. El agente termina la tarea con la información que tiene y dice explícitamente qué no pudo comprobar. Mucho mejor que un dato inventado con toda la confianza del mundo.

    Y una cuarta que a veces es la correcta: parar y escalar al humano. Si la herramienta caída era imprescindible para la tarea, seguir es peor que rendirse. Igual que con los guardrails de ejecución, la decisión de frenar es parte del diseño, no un fallo.


    Cómo saber si tu breaker está bien calibrado

    Un breaker sin métricas es un valor mágico que alguien puso hace seis meses. Registra el cambio de estado con onStateChange y mira tres números:

    Aperturas por hora y por herramienta. Si una tool abre 5 veces por hora contra una API que su proveedor jura estar sana, tu umbral es demasiado bajo o estás contando 4xx que no deberías. Revisa el clasificador antes que el umbral.

    Tiempo total en OPEN. Es tu indisponibilidad real de esa capacidad. Si una herramienta pasa el 20% del día en OPEN, el problema ya no es el breaker: es el proveedor, y toca renegociarlo o buscar alternativa.

    Ratio de half-open que vuelven a abrir. El indicador de flapping. Por encima del 70% significa que tu resetTimeoutMs es demasiado corto y estás probando un servicio que aún no se ha levantado, gastando una llamada de tool en cada intento. Alarga el backoff de forma progresiva: 30s, 60s, 2 min. La versión de arriba usa un resetTimeoutMs fijo; para escalarlo, multiplícalo por el número de aperturas consecutivas antes de asignar openedAt.

    Y una cuarta que solo existe en agentes: qué hizo el modelo después de recibir el fallback. Loguea la siguiente tool call tras un toolUnavailable. Si el modelo vuelve a llamar a la herramienta caída, tu mensaje no está siendo lo bastante claro y toca reescribirlo. Los pasos que se ahorra el agente los ves directamente en el consumo de tokens por tarea.


    Por dónde empezar con el circuit breaker en tu agente

    Coge tu agente. Mira la tool que llama al servicio externo menos fiable — todos tenemos una. Ponle un timeout explícito, un breaker propio con isFailure que ignore los 4xx de validación, y un mensaje de fallback escrito para el modelo y no para tu log.

    Esa única herramienta es el 80% del beneficio. El resto es replicar el patrón.

    La idea de fondo: en un agente, cualquier mecanismo de defensa que no le hable al modelo se queda a medias. Puedes cortar la petición HTTP, pero si no le explicas al LLM qué ha pasado y qué esperas de él, el modelo rellenará el hueco con lo que se le ocurra. Y lo que se le ocurre suele ser caro.

    Esta forma de pensar la arquitectura — decidir antes de escribir código qué hace el sistema cuando algo falla — es exactamente el enfoque del curso Construye con IA: de la idea al producto. Y si quieres ver estos patrones montados sobre proyectos reales, con las métricas puestas y funcionando, en Dominicode Labs es donde los estamos rodando.


    Preguntas frecuentes

    ¿Qué diferencia hay entre un circuit breaker y un simple retry con backoff?

    El retry insiste; el breaker deja de insistir. Son complementarios: el backoff resuelve el fallo puntual dentro de una misma llamada, y el breaker resuelve el fallo sostenido a lo largo de muchas llamadas. Sin breaker, tu retry con backoff se ejecuta entero en cada una de las 14 iteraciones del agente contra un servicio que lleva minutos caído.

    ¿Cuántos fallos deben abrir el circuito de una tool?

    Entre 3 y 5 dentro de una ventana de 60 segundos funciona bien como punto de partida. Con umbral 1 o 2 abres por un pico transitorio; por encima de 8 el agente ya habrá gastado medio presupuesto de pasos antes de que el breaker reaccione. Ajústalo por criticidad: más tolerancia en herramientas imprescindibles, menos en las prescindibles.

    ¿Debe contar un error 400 de una tool para abrir el circuito?

    No. Un 400, un 404 o un 422 casi siempre significan que el modelo mandó argumentos mal formados, no que la API esté rota. Si los cuentas, acabas abriendo el circuito de un servicio sano por culpa del LLM y dejando al agente sin una herramienta que funcionaba. Cuentan los 5xx, los timeouts, los errores de red y los 429.

    ¿Dónde guardo el estado del breaker si mi agente corre en serverless?

    En un almacén compartido tipo Redis, con el nombre de la herramienta como clave. Si lo dejas en memoria de proceso, cada instancia fría descubre por su cuenta que el proveedor está caído y pagas ese descubrimiento tantas veces como instancias tengas. Para un servidor de larga vida, el ámbito de módulo basta.

    ¿El circuit breaker sustituye al límite de pasos del agente?

    No, resuelven cosas distintas. El límite de pasos acota cuánto puede trabajar el agente en total; el breaker impide que una herramienta rota consuma esos pasos sin aportar nada. Van juntos: el breaker devuelve el control rápido y con instrucciones, y el límite de pasos sigue siendo la red de seguridad final.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • GPT-6 Astra: precio, API y el asterisco de los 272.000 tokens

    GPT-6 Astra: precio, API y el asterisco de los 272.000 tokens

    OpenAI anunció GPT-6 Astra el 3 de septiembre de 2026. Greg Brockman, presidente de la compañía, lo llamó un generational leap y dijo que podría verse como la llegada de la AGI.

    Yo abrí la tabla de precios y me quedé mirando un asterisco.

    El asterisco dice esto: cualquier request que supere los 272.000 tokens de input dobla la tarifa de input y la de caché, y multiplica el output por 1,5. Para la request entera. No para los tokens que se pasan del umbral.

    Ahora piensa en tu agente. El que arrastra el historial de tool calls y va acumulando contexto en cada iteración. Ese agente no cruza el umbral cuando tú lo decides: lo cruza en la iteración 14, cuando una herramienta devuelve 6.000 tokens de logs más de lo habitual. Y esa request, completa, pasa a costar casi el doble.

    Mi tesis: Astra es un bisturí caro para tareas largas y autónomas, no el reemplazo por defecto de tu modelo de trabajo. Y la decisión de usarlo no se toma leyendo benchmarks, se toma leyendo tu contador de tokens.


    Qué es GPT-6 Astra y dónde puedes usarlo

    GPT-6 Astra es el modelo de razonamiento de gama alta de OpenAI, lanzado el 3 de septiembre de 2026 y orientado a tareas largas y autónomas: computer use, respuesta a incidentes y refactors de varias horas. En la API se identifica como gpt-6-astra, admite 1,05 millones de tokens de contexto y su knowledge cutoff es el 30 de abril de 2026. Cuesta $10 por millón de tokens de input y $50 de output, con una tarifa premium que dobla el input a partir de 272.000 tokens por request.

    Está en la API de OpenAI, en Azure y en Bedrock, y en ChatGPT para Plus, Pro, Business y Enterprise con rollout escalonado. Los primeros en tenerlo fueron los clientes del programa de ciberseguridad de OpenAI. Ese detalle no es casual: mira la fila de ExploitBench más abajo.

    Los números de contexto son los que esperas de un modelo pensado para tareas largas: 1,05 millones de tokens de contexto máximo, hasta 922.000 de input y hasta 128.000 de output.

    Y sabe usarlos. OpenAI mide 100% en MRCR v2 8-needle en la banda de 256K–512K, y 96,3% en la banda de 512K–1M. Recuperación casi perfecta en contextos enormes.

    Aquí está la ironía del lanzamiento: el modelo es excelente con contexto largo, y el precio te empuja a no usarlo. La capacidad técnica y el incentivo económico apuntan en direcciones opuestas.


    Cuánto cuesta GPT-6 Astra: precio de la API y el asterisco

    GPT-6 Astra cuesta $10 por millón de tokens de input y $50 por millón de output, según la ficha oficial del modelo en la API de OpenAI. Y viene con un asterisco: si una request supera los 272.000 tokens de input, se aplica tarifa premium a la request entera —input y caché al doble, output ×1,5—, no solo al exceso.

    Estas son las tarifas por millón de tokens:

    Concepto Tarifa base Si la request pasa de 272.000 tokens de input
    Input $10 $20
    Cached input $1 $2
    Cache write $12,50 $25
    Output $50 $75

    Hay además un fast mode que, según OpenAI, cobra el doble a cambio de hasta ~2,5x de velocidad. Útil para tareas interactivas, irrelevante para un batch nocturno.

    Y un dato que conviene tener presente: OpenAI ha puesto a Astra exactamente al mismo precio que Claude Fable 5.1, $10 de entrada y $50 de salida. Nadie está compitiendo por precio en la gama alta.

    Vamos con el cálculo que importa.

    Imagina una request de tu agente con 270.000 tokens de input y 8.000 de output:

    • Input: 270.000 × $10 / 1M = $2,70
    • Output: 8.000 × $50 / 1M = $0,40
    • Total: $3,10

    Ahora una tool devuelve 5.000 tokens de logs más de lo normal. La request sube a 275.000 de input:

    • Input: 275.000 × $20 / 1M = $5,50
    • Output: 8.000 × $75 / 1M = $0,60
    • Total: $6,10

    Un 1,9% más de tokens de input. Un 97% más de factura.

    Multiplícalo por 100 requests al día y tienes $310 frente a $610. Al mes (30 días), $9.000 de diferencia por 5.000 tokens de logs que nadie revisó.

    Y ojo con dar por hecho que la caché te salva.

    Lo que está documentado es que el multiplicador se dispara por los tokens de input de la request y que afecta también a la tarifa de caché. Lo que no dice ninguna fuente es si los tokens servidos desde caché cuentan para llegar a los 272.000.

    Hay un indicio de que sí: en la API de OpenRouter, el override de tarifa de esta familia de modelos se indexa por min_prompt_tokens, y los tokens de caché siguen siendo prompt tokens. Si 260.000 de tus 280.000 tokens vienen de caché y el umbral los cuenta, esa caché pasa de $1 a $2 el millón igual.

    Lanza una request de prueba y mira la factura antes de montar tu estrategia de caché alrededor de ese número.

    Cómo evitar cruzar el umbral sin darte cuenta

    Tres cosas, en este orden:

    1. Mide antes de enviar, no después. Si tu telemetría te dice el coste al final del mes, ya es tarde. Necesitas el contador de tokens de input por request, en vivo y con alertas. Lo desarrollé paso a paso en cómo medir el consumo de tokens de un agente de IA.
    2. Poda el historial de forma agresiva. Resume los tool outputs antiguos, trunca los logs a las líneas relevantes y no le metas el repositorio entero "por si acaso". Un agente que necesita 270.000 tokens de contexto casi siempre tiene un problema de diseño, no de memoria.
    3. Pon un techo duro por request y falla rápido. Mejor abortar y partir la tarea que descubrir el gasto en la facturación.

    GPT-6 Astra vs Claude Opus 5 y GPT-5.6 Sol: benchmarks y letra pequeña

    GPT-6 Astra supera a Claude Opus 5 y a GPT-5.6 Sol en tareas agénticas largas, pero empata con Opus 5 en código de frontera. Estas son las cifras que publicó OpenAI:

    Benchmark Astra GPT-5.6 Sol Claude Opus 5
    OSWorld 2.0 (computer use) 72,6% 65,7% 70,2%
    Terminal-Bench 4.0 57,7% 37,3% 52,3%
    FrontierMath Tier 4 v2 97,6% 83,0% 73,2%
    GPQA Diamond 96,0% 94,6% 93,7%
    ExploitBench 100% 78,5% 70,0%
    ARC-AGI-3 (harness con estado) 99,9% 7,8% 30,2%
    SRE-Bench 88,0% 55,9% —
    DeepSWE v1.1 74,1% — 69,9%
    FrontierCode 1.1 53,3% — 53,4%

    En computer use, además, resuelve ese 72,6% en unos 40 minutos por tarea según OpenAI: tarda un 47% menos que los ~75 minutos de Sol. Si automatizas navegador o escritorio, ahí hay una mejora real que notas en la latencia y en el número de reintentos.

    Ahora la letra pequeña.

    El 99,9% de ARC-AGI-3 no es tuyo. Ese resultado depende de un harness con estado y caro, montado para el benchmark. En llamadas stateless normales a la API —las que hace tu código— la puntuación cae, según la organización del benchmark, al rango del ~17–63%. La diferencia entre 99,9% y 17% no está en el modelo: está en la infraestructura que lo envuelve. Cuando veas ese número en un hilo de Twitter, lo que estás viendo es un sistema completo, no un endpoint.

    Y el precio por token miente. En el Intelligence Index de Artificial Analysis, Astra y GPT-5.6 Sol empatan a 61 puntos, y Astra cuesta 2,5 veces más por token ($7,70 frente a $3,08 por millón, precio mezclado). Titular fácil: "pagas 2,5x por lo mismo". Falso.

    Correr ese índice consumió 42M de tokens de output con Astra y 70M con Sol. Astra razona menos en voz alta y llega antes. Por eso el coste por tarea sale $1,67 frente a $0,95: la brecha por token es 2,5x, la brecha por tarea es 1,8x. Ambos números son de la variante max, que es la que mide Artificial Analysis — con menos reasoning effort la cuenta cambia.

    Es la misma lección que con Gemini 3.8 Flash y su coste por tarea: el proveedor te da el precio por token, tu arquitectura decide el coste por tarea. Compara siempre lo segundo.

    Un aviso antes de que abras la calculadora: las fuentes públicas no se ponen de acuerdo sobre el precio de Sol. OpenRouter lo lista a $2/$10 por millón; Artificial Analysis calcula con $4/$20. No hagas números con una cifra que leíste en un hilo. Mira la tabla oficial de OpenAI el día que vayas a decidir, y crúzalo con lo que ya sabes de la API de GPT-5.6 en la práctica.

    ¿Y lo de la AGI? En FrontierCode 1.1, código de frontera, Astra saca 53,3% y Opus 5 saca 53,4%. Empate técnico. Un modelo que insinúa AGI no empata en programación difícil con un modelo de la generación anterior. Que es más o menos lo que ya se veía en la comparativa de Opus 5, GPT-5.6 y Kimi K3: las diferencias de frontera son estrechas y el marketing es ancho.


    Límites de la API de GPT-6 Astra: lo que no te da

    Antes de planificar una migración, comprueba que tu stack sobrevive a estos límites:

    • Tool calling solo vía Responses API. Si tu agente vive en Chat Completions, no hay herramientas. Migras o no usas Astra.
    • No hay fine-tuning, ni Realtime, ni Assistants, ni generación nativa de media. Cualquier flujo que dependa de eso se queda fuera.
    • Reasoning effort: low, medium, high, xhigh, max. El valor none existe en la API, pero gpt-6-astra no lo acepta. Este modelo siempre razona, y ese razonamiento se factura como output a $50 el millón. No puedes apagarlo para una clasificación tonta.
    • Zero Data Retention solo para clientes "elegibles". No es una promesa universal. Si tienes un requisito de cumplimiento, confírmalo por escrito antes de meter datos de cliente.

    Cómo llamar a GPT-6 Astra desde TypeScript

    Lo mínimo que funciona, con Responses API y control de razonamiento:

    import OpenAI from "openai";
    
    const client = new OpenAI();
    
    const incidente =
      "PagerDuty #4821: latencia p99 por encima de 3s en checkout-api desde las 02:14 UTC";
    
    // Con gpt-6-astra el tool calling SOLO existe en la Responses API.
    // En Chat Completions no tienes herramientas con este modelo.
    const response = await client.responses.create({
      model: "gpt-6-astra",
      // low | medium | high | xhigh | max. Este modelo no acepta "none":
      // siempre razona, y ese razonamiento se paga como output.
      reasoning: { effort: "high" },
      input: [
        { role: "developer", content: "Eres un SRE. Diagnostica y propón un fix." },
        { role: "user", content: incidente },
      ],
      tools: [
        {
          type: "function",
          name: "query_logs",
          description: "Consulta los logs de un servicio en una ventana temporal",
          parameters: {
            type: "object",
            properties: {
              service: { type: "string" },
              since: { type: "string", description: "Fecha ISO 8601" },
            },
            required: ["service", "since"],
            additionalProperties: false,
          },
          strict: true,
        },
      ],
    });
    
    console.log(response.usage?.input_tokens, response.usage?.output_tokens);
    

    Y el guardarraíl que yo pondría antes de cada llamada, no después:

    const PREMIUM_INPUT_THRESHOLD = 272_000;
    const SAFETY_MARGIN = 20_000; // lo que puede crecer el input dentro de la iteración
    
    export function assertBelowPremiumTier(estimatedInputTokens: number) {
      if (estimatedInputTokens > PREMIUM_INPUT_THRESHOLD - SAFETY_MARGIN) {
        throw new Error(
          `Contexto de ${estimatedInputTokens} tokens: la request entraría en tarifa premium. Poda el historial o parte la tarea.`
        );
      }
    }
    

    Veinte mil tokens de margen parecen exagerados hasta que ves lo que ocupa un git diff grande o un volcado de logs. Prefiero abortar la iteración a pagar el doble por una request que nadie decidió hacer.


    Cuándo merece la pena GPT-6 Astra (y cuándo no)

    Caso de uso ¿Astra? Por qué
    Agentes autónomos de horas o días (refactors grandes, migraciones) Sí 57,7% en Terminal-Bench 4.0 frente al 52,3% de Opus 5 y el 37,3% de Sol
    Computer use y automatización de escritorio o navegador Sí 72,6% en OSWorld 2.0 y un 47% más rápido por tarea que Sol
    Incidentes de producción, on-call, postmortems Sí 88,0% en SRE-Bench frente al 55,9% de Sol
    Seguridad ofensiva autorizada Sí 100% en ExploitBench; es a quien OpenAI dio acceso primero
    Razonamiento matemático de frontera Sí 97,6% en FrontierMath Tier 4 v2 frente al 73,2% de Opus 5
    Código del día a día: features, bugs, PRs No 53,3% vs 53,4% de Opus 5 en FrontierCode 1.1. Empate, pagando más
    Chat, soporte, RAG conversacional No Pagas un razonamiento que nadie pidió y que no puedes desactivar
    Clasificación y extracción en volumen No El peor caso posible: input largo, output corto, tarifa de gama alta
    Cualquier flujo con fine-tuning, Realtime o Assistants No No existen para este modelo

    La lectura corta: si la tarea la termina una persona en diez minutos, Astra es caro. Si la tarea son ocho horas de un senior peleándose con una terminal, es barato.

    Y esto no va de elegir un modelo, va de enrutar por tarea. Modelo barato por defecto, escalado a Astra solo cuando la tarea es larga, autónoma y verificable. Es el mismo criterio que aplico en el curso Construye con IA: de la idea al producto: la arquitectura decide la factura, no el modelo.


    Qué hacer esta semana

    Una sola cosa: instrumenta el contador de tokens de input por request y mira cuántas de tus llamadas actuales caen entre 200.000 y 300.000 tokens.

    Ese histograma es tu exposición real al tier premium. Si tienes una cola larga acercándose a 272.000, no tienes un problema de modelo: tienes un problema de gestión de contexto que hoy te sale barato y con Astra te costaría el doble.

    Arregla eso primero. Después decide si necesitas el bisturí.

    En Dominicode Labs estamos midiendo coste por tarea de estos modelos sobre proyectos reales, con los routers y los guardarraíles que usamos en producción.


    Preguntas frecuentes

    ¿Qué es GPT-6 Astra?

    Es el modelo de razonamiento de gama alta de OpenAI, anunciado el 3 de septiembre de 2026. En la API se identifica como gpt-6-astra, admite 1,05 millones de tokens de contexto (hasta 922.000 de input y 128.000 de output) y su knowledge cutoff es el 30 de abril de 2026. Está disponible en la API de OpenAI, Microsoft Azure y Amazon Bedrock, y en ChatGPT para Plus, Pro, Business y Enterprise.

    ¿Cuánto cuesta GPT-6 Astra?

    $10 por millón de tokens de input y $50 por millón de output. El input cacheado son $1 y la escritura de caché $12,50. Si una request supera los 272.000 tokens de input, se aplica tarifa premium a toda la request: input y caché al doble ($20 y $2) y output ×1,5 ($75). Hay además un fast mode que cobra el doble a cambio de hasta ~2,5x de velocidad.

    ¿Debo migrar mis agentes a GPT-6 Astra?

    Solo los que ejecutan tareas largas y autónomas: computer use, respuesta a incidentes, refactors de varios días, seguridad ofensiva autorizada. Para el resto de tu producto —chat, clasificación, código del día a día— estarías pagando un razonamiento más caro para obtener prácticamente el mismo resultado. Enruta por tarea, no cambies el modelo por defecto.

    ¿Qué pasa exactamente si mi request supera los 272.000 tokens de input?

    Se aplica tarifa premium a toda la request, no solo al exceso: input y caché al doble, output multiplicado por 1,5. Pasar de 270.000 a 275.000 tokens sube un ejemplo típico de $3,10 a $6,10. Por eso el guardarraíl va antes de la llamada y con margen, no después de leer la factura.

    ¿Justifica su precio lo que OpenAI insinúa sobre la AGI?

    OpenAI lo insinúa: Greg Brockman habló de un generational leap que podría verse como la llegada de la AGI. Los datos son más modestos. En FrontierCode 1.1 empata con Claude Opus 5 (53,3% frente a 53,4%), y su 99,9% en ARC-AGI-3 depende de un harness con estado y caro que tú no tienes: en llamadas stateless normales baja al rango ~17–63%. Es el mejor modelo para ciertas tareas largas. No es otra categoría de cosa.

    ¿GPT-6 Astra es mejor que Claude Opus 5?

    En tareas agénticas largas sí; en código del día a día no. Astra gana en Terminal-Bench 4.0 (57,7% frente a 52,3%), OSWorld 2.0 (72,6% frente a 70,2%) y FrontierMath Tier 4 v2 (97,6% frente a 73,2%). Pero en FrontierCode 1.1 empatan: 53,3% Astra contra 53,4% Opus 5. Para features, bugs y PRs, Astra no te da más y te cuesta más.

    ¿GPT-6 Astra soporta tool calling en Chat Completions?

    No. Con gpt-6-astra las herramientas solo funcionan a través de la Responses API. Tampoco hay fine-tuning, Realtime, Assistants ni generación nativa de media. Si tu agente depende de alguna de esas piezas, la migración es de arquitectura, no de cambiar el string del modelo.

    ¿Es Astra 2,5 veces más caro que GPT-5.6 Sol?

    Por token sí: $7,70 frente a $3,08 por millón en el precio mezclado de Artificial Analysis. Por tarea la brecha se estrecha a 1,8x — $1,67 frente a $0,95 — porque correr el Intelligence Index consumió 42M de tokens de output con Astra y 70M con Sol. Ojo: las fuentes públicas se contradicen sobre el precio de Sol (OpenRouter lo lista a $2/$10, Artificial Analysis calcula con $4/$20), así que haz tus números contra la tabla oficial de OpenAI el día que vayas a decidir.

    ¿Se puede desactivar el razonamiento de GPT-6 Astra para bajar el coste?

    No del todo. Con gpt-6-astra el reasoning effort admite low, medium, high, xhigh y max; el valor none que sí existe en la API para otros modelos aquí no está disponible. Puedes bajarlo a low para reducir tokens de razonamiento, aunque si tu caso de uso no necesita razonar en absoluto, la respuesta correcta no es bajar el effort: es usar otro modelo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Gemini 3.8 Flash: mismo precio por token, tu factura sube un 40%

    Gemini 3.8 Flash: mismo precio por token, tu factura sube un 40%

    Google anunció Gemini 3.8 Flash el 2 de septiembre de 2026 con el mismo precio por token que su antecesor: $0.75 por millón de tokens de entrada y $3.75 de salida. Ni un céntimo de diferencia en la tabla de precios.

    Artificial Analysis lo midió ese mismo día. El coste real por tarea completada de Gemini 3.8 Flash es un 40% más alto que el de Gemini 3.7 Flash.

    No es un error de facturación. Es el modelo haciendo exactamente lo que Google prometió: pensar más. Y pensar más se paga en tokens de salida.

    Esa es la parte del anuncio del 2 de septiembre que casi nadie está contando. Google presentó el modelo más barato jamás medido a ese nivel de inteligencia y, al mismo tiempo, subió el coste real de cada tarea un 40%. Las dos frases son verdad. Solo una te llega a la tarjeta.

    La tesis de este post: deja de comparar modelos por dólares por millón de tokens y empieza a compararlos por dólares por tarea completada. El precio por token lo pone el proveedor en una tabla. El coste por tarea lo pones tú con tu arquitectura, y la palanca que de verdad controlas se llama nivel de reasoning.

    El coste por tarea es el gasto total en tokens —entrada, salida y razonamiento— dividido entre el número de tareas que terminan con una salida válida. No entre llamadas a la API: entre tareas completadas.


    Qué es Gemini 3.8 Flash y qué lanzó Google el 2 de septiembre de 2026

    Gemini 3.8 Flash es el modelo de propósito general y bajo coste de Google, anunciado el 2 de septiembre de 2026 junto a Gemini 3.8 Flash Cyber, una variante restringida especializada en ciberseguridad. Es el cuarto modelo Flash que Google publica en menos de cuatro meses. Ventana de 1M de tokens de entrada, 66K de salida y knowledge cutoff en marzo de 2026. Disponible en Google AI Studio, la Gemini API, Android Studio, Google Antigravity, Gemini Enterprise, la app de Gemini para AI Pro y Ultra, Search AI Mode y Sheets.

    En benchmarks aguanta el pulso a modelos que cuestan casi siete veces más por token: 73,7% en DeepSWE v1.1 frente al 74,0% de Claude Opus 5, 54,9% en HLE-Verified y un 59 en el Artificial Analysis Intelligence Index, donde empata con GPT-5.6 Sol y Grok 4.6. También supera a modelos frontera mayores en Vals Finance Agent V2 y en el Harvey's Legal Agent Benchmark, que son evals de trabajo profesional, no de acertijos.

    Ese 73,7% contra el 74,0% de Opus 5 es la noticia de verdad. Es la misma tendencia que ya se veía en la comparativa de Opus 5, GPT-5.6 y Kimi K3 y en el repaso de Grok 4.5, Fable 5 y DeepSeek V4 para programar: la distancia entre la gama alta y la gama media se cierra por abajo.

    Hay otro dato que me importa. En el Gray Swan IPI, que mide robustez frente a inyección indirecta de prompts, un 5,5% de los ataques tuvo éxito: más de uno de cada veinte intentos. Si tu agente lee contenido que no controlas, ese 5,5% es tu problema, y se resuelve con defensas contra inyección indirecta de prompts, no con fe en el modelo.


    Cuánto cuesta Gemini 3.8 Flash: el precio por token es marketing, el coste por tarea es tu factura

    Aquí está la contradicción, medida de forma independiente por Artificial Analysis:

    Métrica Gemini 3.7 Flash Gemini 3.8 Flash
    Precio entrada / salida (por 1M tokens) $0.75 / $3.75 $0.75 / $3.75
    Tokens de salida por tarea referencia 48.000 (+30%)
    Coste por tarea, high reasoning referencia $0.58 (~+40%)

    Lee la tabla dos veces. La fila del precio no se mueve. La fila que pagas sube un 40%.

    Y ojo con el desglose, porque ese 30% de salida no explica por sí solo el 40% de subida. Con 48.000 tokens de salida a $3.75 el millón, la salida son unos $0.18 de los $0.58: el resto es entrada. Lo que dispara la factura son los turnos. Cada vuelta extra del agente reenvía el contexto acumulado, así que más razonamiento no solo genera más tokens de salida, también multiplica los de entrada. Por eso un +30% de salida termina en un +40% de coste por tarea.

    $0.58 por tarea del Intelligence Index es, según esa misma medición, el coste más bajo jamás registrado a ese nivel de inteligencia. El titular es justo. Pero si vienes de 3.7 Flash con un producto en marcha, tu unit economics acaba de empeorar un 40% sin que hayas tocado una línea de código. Es el mismo patrón que ya conté con el coste de los subagentes al cambiar de modelo: la factura se mueve sola cuando cambia el comportamiento del modelo, no cuando cambias tú el código.

    Del coste por tarea de Opus 5 o GPT-5.6 Sol no doy cifra porque no la tengo medida con el mismo eval, y compararlas de oído sería justo el error que denuncia este post. Lo público es el precio por token: Opus 5 cuesta $5.00 y $25.00 por millón, GPT-5.6 Sol $4.00 y $20.00. Sirve para situar la escala, no para decidir.

    Todas las cifras de coste de este post proceden de la medición independiente publicada por Artificial Analysis el 2 de septiembre de 2026, y los precios del anuncio oficial de Google de esa misma fecha. Verificadas el 3 de septiembre de 2026. Son precios introductorios: expiran el 31 de diciembre de 2026.


    Por qué sube el coste por tarea de Gemini 3.8 Flash: 48.000 tokens de salida

    La causa está publicada y no tiene misterio. Gemini 3.8 Flash gasta una media de 48.000 tokens de salida por tarea, un 30% más que 3.7 Flash, y da más turnos en las evals agénticas.

    Traducido: razona más pasos antes de responder. Ese razonamiento se factura como salida, que es el token caro. A $3.75 el millón, cada vuelta extra de pensamiento tiene precio.

    Y no solo pagas más: esperas más. Artificial Analysis midió que el tiempo medio por tarea en high reasoning sube de 2,2 a 2,5 minutos. Un 14% más de latencia en cada tarea de tu producto, que con un usuario delante se nota antes que en la factura.

    Y si trabajas en español el efecto se acumula: el mismo contenido consume más tokens que en inglés por cómo funciona el tokenizador, algo que desglosé en el post sobre cuánto te cuesta de más escribir en español. Más tokens por razonar, multiplicado por más tokens por idioma, sobre el token que más caro se paga.


    Cómo medir tu coste por tarea real

    Para dejar de discutir con tablas de precios ajenas solo hace falta contar el usage de cada respuesta y dividirlo entre tareas completadas. No entre llamadas: entre tareas que terminaron bien.

    // task-meter.ts — mide lo que pagas, no lo que anuncia el pricing
    type Usage = { input: number; output: number };
    
    // Precio introductorio de Gemini 3.8 Flash (hasta el 31/12/2026), USD por token
    // Desde el 01/01/2027: { input: 1.50 / 1_000_000, output: 7.50 / 1_000_000 }
    const PRICE = { input: 0.75 / 1_000_000, output: 3.75 / 1_000_000 };
    
    type ApiResponse = Record<string, any>;
    
    // Los tokens de razonamiento se facturan como salida: súmalos siempre.
    function readUsage(res: ApiResponse): Usage {
      const u = res.usageMetadata ?? res.usage ?? {};
      return {
        input: u.promptTokenCount ?? u.input_tokens ?? 0,
        output: (u.candidatesTokenCount ?? u.output_tokens ?? 0) + (u.thoughtsTokenCount ?? 0),
      };
    }
    
    export class TaskMeter {
      private input = 0;
      private output = 0;
      private tasks = 0;
    
      track(res: ApiResponse) {
        const { input, output } = readUsage(res);
        this.input += input;
        this.output += output;
      }
    
      // Solo cuenta la tarea si el resultado es válido de verdad.
      completed() {
        this.tasks += 1;
      }
    
      report() {
        const cost = this.input * PRICE.input + this.output * PRICE.output;
        // Sin tareas completadas no hay media: devuelve el gasto en bruto, no NaN.
        if (this.tasks === 0) {
          return { tareas: 0, outputPorTarea: 0, costePorTarea: 0, costeTotal: +cost.toFixed(4) };
        }
        return {
          tareas: this.tasks,
          outputPorTarea: Math.round(this.output / this.tasks),
          costePorTarea: +(cost / this.tasks).toFixed(4),
          costeTotal: +cost.toFixed(4),
        };
      }
    }
    

    El detalle que separa esta métrica de un contador inútil está en completed(). Una tarea cuenta cuando la salida pasa tu validación, no cuando la API devuelve 200. Si el modelo responde un JSON que tu esquema rechaza, has pagado tokens y no has completado nada: eso encarece el coste por tarea, y así debe ser. Yo cierro ese bucle con un safeParse de Zod antes de llamar a completed(), que es justo el tipo de frontera que trabajo en el curso de validación y transformación de datos con Zod.

    Un aviso para que no te asustes de tu propio medidor: promptTokenCount incluye los tokens servidos desde caché de contexto, que se facturan más baratos. Si usas caching, el número que saques será algo pesimista.

    Ejecútalo una semana con tu carga real y tendrás un número que ninguna nota de prensa te puede dar. Para el instrumental completo, con desglose por turno y por herramienta, tienes la guía para medir el consumo de tokens de un agente de IA.


    Niveles de reasoning en Gemini 3.8 Flash: cuándo usar high, medium o low

    El nivel de reasoning es la palanca de coste más grande que tienes, y la mayoría de proyectos la dejan clavada en el máximo por defecto. Los tres niveles medidos por Artificial Analysis el 2 de septiembre de 2026, sobre las mismas tareas del Intelligence Index:

    Nivel de reasoning Intelligence Index Coste por tarea Ahorro vs high
    high 59 $0.58 —
    medium 57 $0.41 −29%
    low 52 $0.24 −59%

    Ahí está el argumento entero en dos números: bajar de high a medium te ahorra un 29% del coste por tarea y cuesta 2 puntos de Intelligence Index, 57 frente a 59. Bajar a low ahorra un 59% y cuesta 7 puntos. Si tu tarea se valida con un esquema, esos 7 puntos no los vas a notar; el 59% sí.

    Mi regla por defecto, la misma que aplico con cualquier modelo que exponga niveles de razonamiento:

    • Low para clasificar, extraer campos, enrutar y resumir. Si puedes validar el resultado con un esquema, no necesitas que el modelo medite.
    • Medium para el trabajo normal de un agente: varios pasos, alguna herramienta, contexto moderado. Este es el defecto sensato, no high.
    • High solo para razonamiento largo con estado, donde un error a mitad de camino te obliga a repetir la tarea entera. Ahí el sobrecoste frente a medium se paga solo, porque un reintento cuesta más que el ahorro.

    Y la consecuencia que a mucha gente se le escapa: si tu producto es un agente de varios pasos, no tienes que elegir un nivel único. Enruta por paso. La extracción va en low, la decisión difícil va en high. Esa granularidad es la diferencia entre un margen sano y uno que se come el precio del plan, y es una de las decisiones de arquitectura que más repito en el curso de Construye con IA.

    En la Gemini API el nivel se fija con thinking_level dentro de generation_config, y acepta low, medium y high:

    const res = await client.interactions.create({
      model: "gemini-3.8-flash",
      input: prompt,
      generation_config: { thinking_level: process.env.GEMINI_THINKING_LEVEL ?? "medium" },
    });
    

    Que salga de una variable de entorno no es cosmético: es lo que te deja bajar el nivel en producción sin desplegar, el día que veas la factura del primer mes.


    El 1 de enero de 2027 te duplica la factura

    Los $0.75 y $3.75 son precio introductorio hasta el 31 de diciembre de 2026. El 1 de enero de 2027 pasan a $1.50 de entrada y $7.50 de salida por millón, según la tabla de precios oficial de la Gemini API. El doble.

    Si estás construyendo un producto y calculas márgenes con el precio de hoy, tienes hasta el 31 de diciembre de espejismo. Un SaaS con un plan de $19 al mes que hoy deja margen holgado puede quedarse en pérdidas el 1 de enero sin que nadie haya tocado nada.

    Con 10.000 tareas al mes, el mismo código y sin tocar nada:

    Nivel de reasoning Factura mensual hoy Factura mensual desde el 1/1/2027
    high $5.800 $11.600
    medium $4.100 $8.200
    low $2.400 $4.800

    Fíjate en la diagonal: pasar de high a medium el 1 de enero te deja en $8.200, todavía por encima de los $5.800 que pagas hoy en high. Bajar un nivel no compensa la subida de precio. Bajar dos, sí.

    Haz el cálculo ahora con el precio de 2027. Si con $1.50 y $7.50 tu unit economics sigue en pie, adelante. Si no, tienes hasta el 31 de diciembre para bajar niveles de reasoning, recortar contexto o cambiar de modelo, que es mucho mejor que enterarte en enero. Este supuesto debería estar escrito en la spec antes de programar nada, con su fecha y su número, como planteo en el libro de Spec-Driven Development.

    Aun con el precio duplicado sigue siendo más barato por token que Opus 5 o GPT-5.6 Sol. Pero "más barato que el más caro" no es un modelo de negocio.


    Gemini 3.8 Flash Cyber y el Fairwind Program: el modelo que no puedes usar

    Gemini 3.8 Flash Cyber es la variante especializada en seguridad y tiene los mejores números del anuncio: 86,2% en CyberGym, que mide detección de vulnerabilidades en C y C++, frente al 77,5% de 3.5 Flash Cyber, el 83,6% de GPT-5.6 Sol y el 85,6% de GPT-5.5-Cyber. En CWE-Bench, parcheo automatizado, marca un 47,2% pass@1 frente al 47,8% del modelo frontera líder, pero a un coste muy inferior. Y supera el 70% de éxito descubriendo vulnerabilidades reales.

    No tienes acceso. Se distribuye por el Fairwind Program a organismos gubernamentales, operadores de infraestructura crítica y mantenedores de software.

    Lo cuento sin drama porque la decisión me parece defendible: un modelo que encuentra vulnerabilidades reales con esa tasa de acierto es igual de bueno encontrándolas para arreglarlas que para explotarlas. Es el mismo patrón de acceso restringido que vimos con Claude Mythos 5.1 y su programa por invitación.

    Lo que sí te llevas es información: si un modelo restringido ya está en el 70% de descubrimiento real, asume que la capacidad ofensiva del otro lado también ha subido. La defensa de tu agente no puede ser que nadie mire. Empieza por los guardrails para agentes con acceso a terminal y base de datos, que es donde más daño se hace.


    Qué hacer con esto hoy

    Instrumenta el coste por tarea antes de cambiar de modelo. Media hora de trabajo: un contador de usage dividido entre tareas que pasan tu validación. A partir de ahí, elegir modelo deja de ser una discusión de opiniones sobre benchmarks ajenos.

    Con ese número, la pregunta ya no es si Gemini 3.8 Flash es más barato que Opus 5, sino cuánto te cuesta a ti completar una tarea, en tu dominio, con tu prompt, en tu idioma. Ahí se gana o se pierde el margen.

    Si quieres ver este instrumental montado sobre proyectos reales, con enrutado por nivel de reasoning y métricas de coste en producción, es de lo que hablamos cada semana en Dominicode Labs.


    Preguntas frecuentes

    ¿Qué es Gemini 3.8 Flash?

    Gemini 3.8 Flash es el modelo de propósito general y bajo coste de Google, anunciado el 2 de septiembre de 2026. Tiene una ventana de 1M de tokens de entrada, 66K de salida y knowledge cutoff en marzo de 2026, y ofrece tres niveles de reasoning (low, medium y high) que cambian tanto la calidad como el coste. Está disponible en Google AI Studio, la Gemini API, Android Studio, Google Antigravity, Gemini Enterprise, la app de Gemini para AI Pro y Ultra, Search AI Mode y Sheets.

    ¿Merece la pena migrar de Gemini 3.7 Flash a Gemini 3.8 Flash?

    Depende de si tu carga aprovecha el razonamiento extra. Gemini 3.8 Flash puntúa más alto en benchmarks agénticos, pero al mismo precio por token consume 48.000 tokens de salida por tarea, un 30% más que 3.7 Flash, y encadena más turnos, así que el coste por tarea sube alrededor de un 40%. Si tus tareas son clasificación, extracción o enrutado, quédate en 3.7 Flash o migra a 3.8 Flash con el reasoning en low; si son cadenas largas con herramientas donde un fallo obliga a repetir todo el trabajo, la migración se paga sola.

    ¿Cuánto cuesta realmente Gemini 3.8 Flash?

    Depende de qué midas. Por token, $0.75 la entrada y $3.75 la salida por millón, precio introductorio hasta el 31 de diciembre de 2026. Por tarea completada del Intelligence Index de Artificial Analysis, $0.58 con high reasoning, $0.41 con medium y $0.24 con low. La segunda cifra es la que se parece a tu factura.

    ¿Por qué sube el coste por tarea si el precio por token no ha cambiado?

    Porque el modelo genera más tokens. Gemini 3.8 Flash gasta 48.000 tokens de salida por tarea de media, un 30% más que 3.7 Flash, y ejecuta más turnos en tareas agénticas. Ese 30% de salida, más el contexto que se reenvía en cada turno extra y que se factura como entrada, deja el coste por tarea alrededor de un 40% por encima aunque la tabla de precios sea idéntica.

    ¿Qué pasa el 1 de enero de 2027 con el precio?

    Se acaba el precio introductorio y pasa a $1.50 de entrada y $7.50 de salida por millón: exactamente el doble. Si has calculado los márgenes de tu producto con el precio de 2026, rehaz los números con los de 2027 antes de fijar tus planes de precios.

    ¿Puedo usar Gemini 3.8 Flash Cyber?

    Salvo que trabajes en un organismo gubernamental, en un operador de infraestructura crítica o mantengas software ampliamente usado, no. Se distribuye únicamente a través del Fairwind Program. No hay endpoint público ni precio publicado, así que tu referencia para trabajar hoy es Gemini 3.8 Flash estándar.

    ¿Es Gemini 3.8 Flash mejor que Claude Opus 5 para programar?

    En DeepSWE v1.1 saca 73,7% frente al 74,0% de Opus 5: prácticamente empate, con Opus 5 a $5.00 y $25.00 por millón frente a $0.75 y $3.75. Para la mayoría de cargas, esa diferencia de tres décimas no justifica el sobrecoste. Para razonamiento muy largo donde un fallo obliga a repetirlo todo, mídelo con tu propio coste por tarea antes de decidir.

    ¿Qué nivel de reasoning debería usar por defecto?

    Medium, no high. Reserva high para tareas largas con estado donde un error a mitad de camino te obliga a rehacer el trabajo entero, y baja a low todo lo que tenga una respuesta verificable con un esquema. Si tu agente tiene varios pasos, enruta el nivel paso a paso en lugar de fijar uno global.

    ¿Cómo se cambia el nivel de reasoning en la Gemini API?

    Con el parámetro thinking_level dentro de generation_config, que en Gemini 3.8 Flash acepta los valores low, medium y high. Una llamada quedaría como generation_config: { thinking_level: "medium" } junto al modelo y el prompt. Léelo siempre de una variable de entorno en lugar de escribirlo a fuego en el código: es lo que te permite bajar el nivel en producción sin desplegar cuando el coste por tarea se dispare.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Claude Fable 5.1: los 3 breaking changes que rompen tu agente

    Claude Fable 5.1: los 3 breaking changes que rompen tu agente

    Ayer por la tarde cambié una línea en un agente que llevaba once semanas en producción sin que nadie lo tocara.

    claude-fable-5 → claude-fable-5-1. Eso es todo lo que pide la guía de migración a Claude Fable 5.1. Deploy, café, a otra cosa.

    En mi cuenta no pasó nada. En la del cliente, abierta el lunes, las conversaciones largas empezaron a devolver un 400 con un mensaje que no había visto nunca: The block is bound to a different conversation.

    El modelo no tenía la culpa. La tenía una función mía que reconstruía el system prompt en cada petición para inyectar la fecha de hoy. Con Fable 5 era invisible. Ahora invalida todos los thinking blocks posteriores y la API rechaza la petición entera.

    Esa es la tesis de este post: actualizar el model ID es una línea; lo que rompe es cómo construyes el array de messages.


    Qué es Claude Fable 5.1 y qué no cambia respecto a Fable 5

    Claude Fable 5.1 es el modelo de razonamiento de gama alta de Anthropic, lanzado el 1 de septiembre de 2026 junto a Claude Mythos 5.1. Mantiene la ficha técnica de Fable 5 —ventana de 1M de tokens, 128K de output, $10 de input y $50 de output por millón— y su retirada no será antes del 1 de septiembre de 2027. Lo único que cambia de precio es el cache read. Lo único que rompe es cómo construyes el array de messages.

    El resto de la ficha tampoco se mueve: mismo tokenizer que Fable 5, thinking adaptativo siempre activo con effort por defecto en high y knowledge cutoff en junio de 2026. La ventana de 1M es a la vez el valor por defecto y el máximo, a precio estándar de principio a fin.

    Dónde puedes llamarlo: la API de Claude como claude-fable-5-1, Amazon Bedrock como anthropic.claude-fable-5-1, Google Cloud y Microsoft Foundry, además de Claude Code, Claude Enterprise y la Claude Platform. Mythos 5.1 (claude-mythos-5-1) es solo por invitación.

    Si vienes de Fable 5, en la ficha técnica no hay nada nuevo que aprender. Lo nuevo está en dos sitios: tres cosas que dejan de funcionar y un precio que cambia la economía de los agentes largos.


    Breaking change 1: tool_choice forzado devuelve 400 en Claude Fable 5.1

    tool_choice: {"type": "any"} y {"type": "tool", "name": "..."} devuelven un 400 invalid_request_error con este mensaje literal:

    tool_choice: type "tool" and "any" are not supported for this model.
    

    Siguen funcionando {"type": "auto"} (el default) y {"type": "none"}. La misma validación se aplica al endpoint de token counting: si tenías un estimador de costes que replicaba el payload, también se cae.

    El motivo tiene sentido. Con el thinking siempre activo, forzar la tool se salta el bloque de razonamiento y el modelo acaba metiéndolo en los argumentos.

    El fix son dos cambios y ninguno es dramático:

    // Antes (Fable 5): forzabas la tool para garantizar JSON válido
    const res = await client.messages.create({
      model: "claude-fable-5",
      max_tokens: 4096,
      tools: [extractInvoice],
      tool_choice: { type: "tool", name: "extract_invoice" }, // 400 en Fable 5.1
      messages,
    });
    
    // Después (Fable 5.1): auto + strict, y la orden va en el prompt
    const res = await client.messages.create({
      model: "claude-fable-5-1",
      max_tokens: 4096,
      tools: [{ ...extractInvoice, strict: true }],
      tool_choice: { type: "auto" },
      messages: [
        ...messages,
        {
          role: "user",
          content: "Usa la tool `extract_invoice` para responder.",
        },
      ],
    });
    

    Un aviso antes de que lo copies: ese mensaje se queda en el historial. Si lo inyectas en cada petición y lo borras en la siguiente, acabas de reproducir el breaking change 3. O lo dejas fijo, o lo mandas como system message de un solo turno, que lo verás más abajo.

    Si lo que buscabas con tool_choice era JSON conforme a un esquema y no una herramienta de verdad, mueve el esquema a structured outputs y quítate la tool de en medio. El esquema pasa a ser el contrato, y un contrato hay que validarlo también en tu lado. Si lo que devuelve el modelo lo compruebas con un if (typeof x === "string"), en el curso de Zod para TypeScript está la versión que no se rompe cuando el esquema crece.


    Breaking change 2: los thinking blocks están atados al modelo que los produjo

    Cada thinking block registra qué modelo lo generó, y la compatibilidad es unidireccional. Fable 5.1 lee los bloques de modelos anteriores. Ningún modelo anterior lee los de Fable 5.1.

    Traducción para quien tiene un router: si tu fallback salta de Fable 5.1 a Opus 5 a mitad de conversación, la API descarta el bloque antes de que el modelo lo vea. No cuenta como input_tokens, no se factura y no te avisa.

    Ese silencio es el problema: tu agente sigue respondiendo, pero razona con menos contexto del que crees y en la traza no hay un error que investigar. Con el header beta thinking-binding-controls-2026-08-01 el descarte se reporta en input_transformations. Enciéndelo en staging antes de migrar.


    Breaking change 3: editar turnos anteriores invalida los thinking blocks

    Este es el que me mordió a mí.

    Modificar cualquier cosa antes de un thinking block —el system, el array de tools o un mensaje anterior— provoca un 400 en la siguiente petición: The block is bound to a different conversation.

    Patrones que invalidan todos los bloques posteriores:

    • Editar, reordenar o eliminar un turno anterior conservando los siguientes.
    • Inyectar texto por petición en un turno anterior (un recordatorio, una línea de estado) que borras en la siguiente.
    • Reconstruir el system prompt o el array de tools entre peticiones de la misma conversación.
    • Servir bytes distintos para la misma imagen o documento en una petición posterior. La comprobación mira los bytes, no la URL, así que una signed URL rotatoria del mismo fichero es válida.

    Patrones que no invalidan nada:

    • Eliminar una racha inicial de thinking blocks, del más viejo primero.
    • Dejar que la compactación o el context editing server-side recorten el historial.
    • Mover marcadores cache_control.
    • Cambiar el effort entre peticiones.

    La regla mental cabe en tres palabras: trata la conversación como append-only.

    Y aquí el detalle que explica por qué a unos les explota y a otros no: la comprobación se aplica a cuentas creadas a partir del 31 de agosto de 2026. En cuentas anteriores la API registra el desajuste pero solo actúa si mandas thinking.block_binding.prefix_mismatch_behavior, y Mythos 5.1 no la aplica nunca. Tu código puede estar roto hoy y no enterarte hasta que un cliente nuevo abra su cuenta.

    Claude Code, claude.ai, Claude Managed Agents y el Agent SDK ya mantienen ese prefijo intacto por ti. El problema es tuyo solo si construyes el array messages a mano, que es lo que hacemos casi todos los que tenemos agentes en producción.

    Los 3 breaking changes de Claude Fable 5.1, en una tabla

    Qué se rompe Cómo se manifiesta Fix
    tool_choice de tipo any o tool 400 invalid_request_error inmediato, también en token counting tool_choice: auto + strict: true, o mover el esquema a structured outputs; la orden de usar la tool, en el prompt
    Thinking blocks de Fable 5.1 enviados a un modelo anterior Silencio: el bloque se descarta, no se factura, nadie avisa Que el router no cambie de modelo a mitad de conversación; header thinking-binding-controls-2026-08-01 para verlo en input_transformations
    Editar system, tools o un turno anterior 400 The block is bound to a different conversation, solo en cuentas creadas desde el 31/08/2026 Historial append-only; recordatorios con system messages de un solo turno; recortes con context editing server-side

    No es la primera vez: ya conté los 2 breaking changes de Claude Opus 5. El patrón se repite en cada release y siempre pilla al mismo tipo de código, el que trata el historial como un array mutable.


    Precio de Claude Fable 5.1: el cache read baja un 75 %

    El cache read de Claude Fable 5.1 cuesta $0,25 por millón de tokens, un 75 % menos que los $1,00 de Fable 5. El resto de precios no se mueve.

    Concepto Fable 5.1 Fable 5
    Input $10 / MTok $10 / MTok
    Output $50 / MTok $50 / MTok
    Cache write 5 min $12,50 / MTok $12,50 / MTok
    Cache write 1 h $20 / MTok $20 / MTok
    Cache read $0,25 / MTok $1,00 / MTok

    El mínimo cacheable sigue en 512 tokens y el Batch API mantiene su 50 % de descuento: $5 de input y $25 de output.

    Pero el número que cambia la arquitectura es otro: en Fable 5.1 el cache read cuesta 0,025× el input base, cuando en el resto de modelos Claude es 0,1×. Es la excepción, no la norma.

    Eso convierte un prefijo grande y estable en algo casi gratis de releer: Anthropic declara un ahorro en torno al 25 % en cargas típicas y hasta el 45 % en trabajo muy agéntico.

    Fíjate en la simetría: los mismos patrones que invalidan los thinking blocks son los que te tiran la caché. Reconstruir el system en cada llamada no era un bug latente; era una factura que llevabas pagando desde antes de migrar.

    Si nunca lo has medido en serio, empieza por cómo funciona el prompt caching en la API de Claude. Después, cómo medir el consumo real de tokens de un agente. Sin esas dos métricas, elegir modelo es una corazonada.


    Tres novedades de Claude Fable 5.1 que sí vale la pena adoptar

    Effort por mensaje (beta, header mid-conversation-output-config-2026-07-01). Cambias el nivel de effort a mitad de conversación sin invalidar la caché de prompt. Súbelo para los pasos difíciles, bájalo para los rutinarios:

    const response = await client.beta.messages.create({
      model: "claude-fable-5-1",
      max_tokens: 4096,
      output_config: { effort: "high" },
      messages: [
        { role: "user", content: "Planifica la migración de SQLite a PostgreSQL." },
        { role: "assistant", content: "1. Exporta los datos. 2. Crea el esquema. 3. Importa y verifica." },
        // El nuevo nivel entra en vigor desde el siguiente turno de usuario
        { role: "system", content: [], output_config: { effort: "low" } }, // sin texto: es un mensaje de control
        { role: "user", content: "Resume el plan en una frase." },
      ],
      betas: ["mid-conversation-output-config-2026-07-01"],
    });
    

    Si tu editor subraya el role: "system" dentro de messages, es que el tipado de tu SDK todavía no lo incluye: actualiza el paquete antes de pelearte con TypeScript.

    System messages de un solo turno (beta, header mid-conversation-system-clear-at-2026-08-21). Es literalmente el sustituto del patrón que ahora rompe. Va dentro del array messages, como un turno más:

    {
      "role": "system",
      "clear_at": "next_user_message",
      "content": "Los resultados están en tu bandeja. Revísala antes de ejecutar más código."
    }
    

    Tiene autoridad de system prompt durante el turno actual y deja de inyectarse en el prompt cuando llega un mensaje de usuario posterior. Se queda en messages y lo sigues mandando igual, así que nada anterior cambia: la caché sigue casando, los thinking blocks siguen válidos y un mensaje limpiado cuesta 0 tokens de input.

    Progress updates entre tool calls (beta, header thinking-display-updates-2026-08-18). Con thinking.display: "updates" recibes como texto los avisos de progreso que el modelo escribe entre llamadas a tools, manteniendo el razonamiento oculto. Con el default "omitted" no llega nada y un turno agéntico de tres minutos parece un cuelgue.


    Dos cambios de comportamiento de Fable 5.1 que verás sin tocar código

    El parallel tool calling es más variable. Fable 5.1 puede hacer una llamada por turno donde Fable 5 agrupaba varias. No baja la calidad de la respuesta, pero cuesta tokens, round trips y tiempo de reloj. Se arregla con una instrucción de una línea pidiendo agrupar las lecturas independientes.

    Y Fable 5.1 reescribe ficheros enteros para cambios pequeños. Donde esperas una edición quirúrgica, te devuelve el fichero entero. Mismo resultado, más tokens de output.


    Claude Fable 5.1 vs Opus 5: la parte incómoda es que no es tu default

    Lo dicen los propios docs de Anthropic: "For most workloads, start with Claude Opus 5". Fable 5.1 se reserva para razonamiento exigente y trabajo agéntico de largo recorrido, o para cuando tus evals con Opus 5 a effort alto se quedan cortas. Opus 5 cuesta $5 de input y $25 de output. La mitad exacta.

    Benchmark Fable 5.1 Fable 5 Opus 5
    Terminal-Bench 4.0 55,8 % 42,0 % 52,3 %
    Terminal-Bench-Science 0.1 52,6 % 24,7 % 29,0 %
    OSWorld 2.0 (partial) 77,9 % 72,9 % 75,4 %
    OSWorld 2.0 (strict) 41,7 % 36,1 % 39,6 %

    Mira Terminal-Bench 4.0: 55,8 % contra 52,3 %. Tres puntos y medio por el doble de precio. En Terminal-Bench-Science son 52,6 % contra 29,0 %, casi veinticuatro puntos, y ahí la decisión se toma sola.

    La elección de modelo dejó de ser global. Se decide por tarea y con tus evals delante. Y si lo montas como router, recuerda el breaking change 2 y no cambies de modelo a mitad de conversación.


    Qué es Claude Mythos 5.1 y quién puede usarlo

    Claude Mythos 5.1 (claude-mythos-5-1) es el mismo modelo subyacente que Fable 5.1 con salvaguardas más permisivas. Comparte specs y precios. Solo por invitación, para participantes de Project Glasswing: organizaciones verificadas de ciberseguridad y ciencias de la vida. Con esas salvaguardas relajadas rinde más, 60,9 % en Terminal-Bench 4.0 frente al 55,8 % de Fable 5.1.

    La noticia no es el modelo, es la separación. Por primera vez Anthropic distingue de forma tan explícita "el modelo que puedes usar" de "el modelo que rinde más si te verifican". Tiene lógica. Parte del rendimiento que pierde un generalista se va en negarse a cosas que en un laboratorio de biología son trabajo normal, como se veía venir en los agentes autónomos aplicados a proteínas.

    Pero tenlo delante antes de comparar capturas de benchmarks: los números de un modelo con salvaguardas relajadas y los del modelo que tú puedes llamar no son comparables.


    Cómo migrar de Claude Fable 5 a Fable 5.1, en 4 pasos

    1. Busca tool_choice en tu repo. Si aparece any o tool, cámbialo a auto con strict: true y mueve la orden al prompt. Media hora.
    2. Busca dónde reconstruyes el system o el array de tools. Cualquier new Date() ahí dentro es una bomba: sácalo a un mensaje de usuario o a un system message de un solo turno.
    3. Audita tu router. Si puede cambiar de modelo a mitad de conversación, activa thinking-binding-controls-2026-08-01 en staging y mira input_transformations.
    4. Mide antes y después. Con el cache read a $0,25 tu arquitectura puede cambiar, pero solo si tienes el número delante.

    El resto está en los docs de novedades de Fable 5.1; el trabajo de verdad está en tus evals.

    Si construyes agentes con Claude y quieres el flujo completo de idea a producto, es lo que enseño en Construye con IA. Y en Dominicode Labs probamos estos patrones de historial append-only y routing por tarea sobre proyectos reales.

    Una release de modelo no se mide por lo que el modelo hace mejor. Se mide por cuántas suposiciones tuyas deja de sostener.


    Preguntas frecuentes

    ¿Tengo que migrar ya de Claude Fable 5 a Claude Fable 5.1?

    No hay urgencia. Fable 5.1 no se retirará antes del 1 de septiembre de 2027. La razón para migrar es económica, no de soporte. Si tu carga es muy agéntica y relee un prefijo grande en cada turno, el cache read a $0,25 justifica la migración por sí solo.

    ¿Por qué Claude Fable 5.1 me devuelve un 400 con tool_choice?

    Porque tool_choice: {"type": "any"} y {"type": "tool", "name": "..."} dejaron de estar soportados en Fable 5.1 y en Mythos 5.1. La API responde un invalid_request_error con el mensaje literal tool_choice: type "tool" and "any" are not supported for this model, y la misma validación se aplica al endpoint de token counting. Solo siguen siendo válidos {"type": "auto"} (el default) y {"type": "none"}. El fix es tool_choice: {"type": "auto"} con strict: true en la definición de la tool y la orden de usarla escrita en el prompt.

    ¿Cómo fuerzo ahora que el modelo llame a una tool concreta?

    No puedes forzarla. Díselo en el prompt —"Usa la tool get_weather para responder"— y comprueba que la haya llamado antes de seguir. Sin tool_choice no hay garantía, solo una instrucción que el modelo cumple casi siempre. Si lo que necesitabas era JSON conforme a un esquema, usa strict: true con tool_choice: auto o mueve el esquema a structured outputs.

    ¿Por qué a mi compañero le da 400 y a mí no, con el mismo código?

    Por la fecha de creación de la cuenta: la comprobación solo se aplica a las creadas a partir del 31 de agosto de 2026. En cuentas anteriores la API registra el desajuste pero no actúa salvo que mandes thinking.block_binding.prefix_mismatch_behavior. Tu código está igual de roto en ambos casos; solo cambia quién se entera.

    ¿Merece la pena Fable 5.1 frente a Opus 5?

    Los propios docs recomiendan empezar por Opus 5, que cuesta la mitad. Fable 5.1 saca tres puntos y medio más en Terminal-Bench 4.0, pero casi veinticuatro en Terminal-Bench-Science. Si tu tarea es razonamiento exigente y agéntico de largo recorrido, la diferencia paga; para el resto, no.

    ¿Puedo usar Claude Mythos 5.1?

    Solo si tu organización está aprobada en Project Glasswing, el programa por invitación para entidades verificadas de ciberseguridad y ciencias de la vida. No hay lista de espera pública: el acceso se pide a través de tu equipo de cuenta de Anthropic, AWS o Google Cloud. Es el mismo modelo con salvaguardas más permisivas y el mismo precio; si no estás dentro, tu referencia es Fable 5.1.

    ¿Puedo mezclar Claude Fable 5.1 y Opus 5 en el mismo agente?

    Sí, pero no dentro de la misma conversación. Los thinking blocks de Fable 5.1 no los lee ningún modelo anterior, así que un router que salte a Opus 5 a mitad de hilo pierde ese razonamiento sin avisarte. Reparte por tarea y arranca conversación nueva al cambiar de modelo, o activa el header thinking-binding-controls-2026-08-01 para ver los descartes en input_transformations.

    ¿Dónde puedo usar Claude Fable 5.1?

    En la API de Claude con el model ID claude-fable-5-1, en Amazon Bedrock como anthropic.claude-fable-5-1, en Google Cloud y en Microsoft Foundry, además de Claude Code, Claude Enterprise y la Claude Platform. No necesitas ningún header beta para llamarlo: los headers solo hacen falta para las funciones nuevas como el effort por mensaje o los system messages de un solo turno.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Human in the loop: tu agente no puede esperar en un await

    Human in the loop: tu agente no puede esperar en un await

    El mensaje llegó a Slack a las 19:42: «El agente quiere reembolsar 38 pagos por 4.120 €. ¿Apruebas?».

    La responsable de soporte de mi cliente, Marta, lo leyó a las 23:10, desde el sofá, y pulsó el botón verde. No pasó nada.

    Bueno, esa noche no pasó nada. Al día siguiente pasó tres veces de más.

    El human in the loop de aquel agente eran catorce líneas: una llamada a Slack y un await esperando la respuesta. En local funcionaba precioso. En producción, la función serverless se había muerto tres horas antes de que nadie pulsara nada, con la conversación entera en memoria.

    El arreglo de urgencia fue relanzar el agente con «el usuario ya aprobó los reembolsos» metido en el prompt. El modelo volvió a planificar desde cero, esta vez encontró 41 pagos fallidos en vez de 38 — habían entrado tres nuevos por la noche — y reembolsó los 41.

    El humano había aprobado 38. El agente ejecutó 41. Y nadie podía decir qué se aprobó exactamente, porque la propuesta solo había existido en la RAM de un proceso muerto.

    Eso no es un bug. Es lo que pasa cuando tratas la aprobación humana como un if.


    Qué es el human in the loop: la aprobación es asíncrona, tu loop no

    El human in the loop (HITL) en un agente de IA es el patrón por el que el agente detiene su ejecución antes de una acción irreversible —mover dinero, enviar emails, borrar datos, desplegar— y solo continúa cuando una persona la aprueba, la edita o la rechaza. La diferencia entre que funcione y que no está en dónde ocurre esa pausa: no dentro del bucle, sino al final de una ejecución que termina y de otra que la retoma.

    El agentic loop es una función: el modelo piensa, pide una herramienta, tú la ejecutas, le devuelves el resultado, repites. Todo dentro de la misma llamada, con el array de mensajes creciendo en memoria.

    Meter una aprobación humana ahí parece trivial. Un if antes de ejecutar, una notificación, y esperas.

    El problema es lo que hay al otro lado de esa espera: una persona. Y en lo que llevo medido, una persona tarda entre cuarenta segundos y dos días. Tu proceso no aguanta ni lo primero.

    Se muere por el timeout de la función serverless. Se muere porque despliegas. Se muere por un reinicio del contenedor, por un OOM, porque el usuario cerró la pestaña y tu handler se canceló. Un await de seis horas no es una espera: es una apuesta a que nada se reinicie en seis horas.

    Y en el caso improbable de que sobreviva, tienes un proceso vivo con la conversación completa en memoria, sin hacer absolutamente nada, ocupando RAM y una conexión abierta. Multiplícalo por doscientas aprobaciones pendientes un lunes por la mañana.

    Hay un tercer problema, y es el que acaba en una reunión incómoda: si el estado vive en memoria, no existe. Nadie puede responder a «¿qué aprobó exactamente Marta el jueves a las 23:10?».

    Así que la regla de diseño es esta: la aprobación humana no es una rama dentro del loop, es un final del loop. El agente termina su ejecución devolviendo un estado «esperando decisión». Una ejecución nueva y distinta, disparada por el webhook de aprobación, lo retoma donde se quedó.

    Un punto de aprobación es una frontera de proceso. Todo lo demás sale de ahí.


    Qué acciones necesitan aprobación humana: los tres niveles de riesgo

    Antes de suspender nada hay que decidir qué merece suspenderse. Y aquí casi todo el mundo se pasa de frenada.

    Un agente que pregunta cuarenta veces al día se desactiva solo. Peor todavía: no se desactiva, y el humano aprende a pulsar «Aprobar» sin leer. Un botón que se pulsa siempre no es un control, es un ritual.

    Clasifica cada herramienta en tres niveles:

    Nivel Ejemplos ¿Aprobación?
    Read-only Buscar pedidos, leer un fichero, consultar saldo Nunca. Ni una sola vez
    Reversible Crear un borrador, etiquetar, abrir un PR, escribir en staging No, pero deja rastro y ten un deshacer
    Irreversible Reembolsar, enviar email, borrar registros, desplegar, publicar Sí, salvo por debajo de un umbral

    El matiz que lo cambia todo: el riesgo no está en la herramienta, está en los argumentos. sendEmail a un destinatario es soporte normal; a doce mil, es una campaña que nadie autorizó. refundPayments de 3 € es ruido; de 4.120 € es una conversación.

    Y el lote es una decisión, no treinta y ocho. Si tu tool reembolsa de uno en uno, el agente pedirá permiso treinta y ocho veces y habrás construido el autoclick con tus propias manos. La herramienta que se aprueba recibe el lote entero y el umbral se calcula sobre el total.

    Por eso la política de aprobación es una función del input, no un booleano en la definición de la tool:

    // tool-policy.ts
    export type RiskLevel = "read_only" | "reversible" | "irreversible";
    export type ApprovalMode = "auto" | "human";
    
    export interface ToolPolicy<TInput> {
      risk: RiskLevel;
      approval: (input: TInput) => ApprovalMode;
    }
    
    const definePolicy = <TInput>(policy: ToolPolicy<TInput>): ToolPolicy<TInput> => policy;
    
    export const toolPolicies = {
      searchOrders: definePolicy<{ status: string }>({
        risk: "read_only",
        approval: () => "auto",
      }),
      draftRefundReport: definePolicy<{ orderIds: string[] }>({
        risk: "reversible",
        approval: () => "auto",
      }),
      refundPayments: definePolicy<{ orderIds: string[]; totalCents: number }>({
        risk: "irreversible",
        // El umbral mira el lote entero, no el pago suelto.
        approval: ({ orderIds, totalCents }) =>
          totalCents > 5_000 || orderIds.length > 1 ? "human" : "auto",
      }),
      sendEmail: definePolicy<{ to: string[]; subject: string }>({
        risk: "irreversible",
        approval: ({ to }) => (to.length > 1 ? "human" : "auto"),
      }),
      deleteCustomers: definePolicy<{ ids: string[] }>({
        risk: "irreversible",
        approval: () => "human",
      }),
    } as const;
    
    export function approvalModeFor(toolName: string, input: unknown): ApprovalMode {
      const policy = toolPolicies[toolName as keyof typeof toolPolicies];
      // Fail-closed: una tool que no está en el mapa NO se ejecuta sola.
      // El día que alguien añada una herramienta y olvide la política, el
      // agente preguntará de más. Ese es el fallo barato.
      if (!policy) return "human";
      return (policy.approval as (input: unknown) => ApprovalMode)(input);
    }
    

    El cast de la última línea es el precio de tener un mapa heterogéneo. Lo pago en un único punto del sistema y no en cada herramienta, que es justo el reparto que quiero.

    Los umbrales no son constantes de por vida. Empieza pidiendo aprobación de todo lo irreversible, y a las tres semanas, con el registro de decisiones delante, súbelos donde el humano lleve treinta aprobaciones seguidas sin rechazar ni una. Si un umbral nunca se ha movido, es que nadie está mirando.

    Y si al terminar la clasificación resulta que todo requiere aprobación, no tienes un agente: tienes un formulario caro. Eso significa que le has dado herramientas que no debía tener, y el arreglo está antes, en el perímetro de ejecución, como conté en guardrails para agentes con acceso a terminal y base de datos.

    Esta tabla, además, se escribe antes que el código. Qué es irreversible en tu dominio es una decisión de producto, no de implementación, y va en la spec junto al resto de reglas del sistema — es literalmente uno de los apartados que defiendo en el libro de Spec-Driven Development.


    Cómo interrumpir y reanudar un agente de IA sin perder el estado

    Ahora el núcleo. El loop tiene que poder terminar a mitad de un turno y volver a arrancar horas después como si nada.

    Declaro las herramientas sin función de ejecución: el modelo puede pedirlas, pero quien decide si se ejecutan soy yo, en mi código. Ese es el punto de control.

    // agent-run.ts
    import { generateText, type ModelMessage, type JSONValue } from "ai";
    import { approvalModeFor } from "./tool-policy";
    import { buildPreview, type ApprovalPreview } from "./preview";
    import { checkpoints } from "./checkpoints";
    import { executeTool } from "./execute-tool";
    // model, SYSTEM_PROMPT y toolSchemas salen de tu configuración del agente.
    // executeTool: (call: PendingCall, opts?: { idempotencyKey?: string }) => Promise<JSONValue>
    import { model, SYSTEM_PROMPT, toolSchemas } from "./config";
    
    export interface PendingCall {
      toolCallId: string;
      toolName: string;
      input: unknown;
    }
    
    export interface PendingApproval extends PendingCall {
      approvalId: string;
      preview: ApprovalPreview;
      requestedAt: string;
      expiresAt: string;
    }
    
    export type AgentOutcome =
      | { status: "completed"; text: string }
      | { status: "awaiting_approval"; runId: string; approval: PendingApproval }
      | { status: "already_resolved" }
      | { status: "exhausted"; stepsUsed: number };
    
    const MAX_STEPS = 12;
    
    // El shape exacto del resultado de tool depende de tu SDK.
    // Este es el de AI SDK 5+; en 4.x era { type: "tool-result", result }.
    export const toolResult = (call: PendingCall, value: JSONValue) => ({
      type: "tool-result" as const,
      toolCallId: call.toolCallId,
      toolName: call.toolName,
      output: { type: "json" as const, value },
    });
    
    export async function runAgent(
      runId: string,
      initial: ModelMessage[],
      stepsUsed = 0,
    ): Promise<AgentOutcome> {
      const messages = [...initial];
    
      for (let step = stepsUsed; step < MAX_STEPS; step++) {
        const turn = await generateText({ model, system: SYSTEM_PROMPT, messages, tools: toolSchemas });
        messages.push(...turn.response.messages);
    
        if (turn.toolCalls.length === 0) return { status: "completed", text: turn.text };
    
        const results: ReturnType<typeof toolResult>[] = [];
    
        for (const [index, call] of turn.toolCalls.entries()) {
          // En AI SDK 4.x los argumentos viajan en call.args, no en call.input
          if (approvalModeFor(call.toolName, call.input) === "auto") {
            results.push(toolResult(call, await executeTool(call)));
            continue;
          }
    
          // Hay una llamada que necesita un humano. El turno se acaba aquí.
          // Las llamadas que quedaban detrás NO se ejecutan, pero necesitan
          // un resultado: el protocolo exige responder a todas las tool calls.
          const deferred = turn.toolCalls.slice(index + 1).map((rest) =>
            toolResult(rest, {
              ok: false,
              deferred: true,
              instruction:
                "No se ejecutó: el turno se detuvo esperando una aprobación humana. " +
                "Si sigue siendo necesaria, vuelve a pedirla después.",
            }),
          );
    
          const approval: PendingApproval = {
            ...call,
            approvalId: crypto.randomUUID(),
            preview: await buildPreview(call),
            requestedAt: new Date().toISOString(),
            expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
          };
    
          await checkpoints.save({
            runId,
            messages,
            partialResults: [...results, ...deferred],
            approval,
            stepsUsed: step + 1, // el presupuesto no se reinicia al reanudar
          });
    
          return { status: "awaiting_approval", runId, approval };
        }
    
        messages.push({ role: "tool", content: results });
      }
    
      return { status: "exhausted", stepsUsed: MAX_STEPS };
    }
    

    Fíjate en el bloque deferred, que es la parte que casi nadie ve venir. Cuando el modelo pide tres herramientas en el mismo turno y la segunda necesita aprobación, no puedes limitarte a guardar y salir: los proveedores exigen que cada tool call tenga su resultado antes de continuar la conversación. Si dejas una huérfana, al reanudar te comes un 400 y no entiendes por qué — el AI SDK tiene hasta un error con nombre propio para esto, MissingToolResultsError.

    El checkpoint guarda tres cosas y las tres hacen falta: los mensajes hasta ese punto, los resultados parciales del turno a medias, y la llamada pendiente con su preview. Eso es todo el agente, serializado. En Postgres, en una tabla con run_id, approval_id, status y un jsonb.

    El resto de la función que expone esto por HTTP es aburrido a propósito: si el status es awaiting_approval, mandas la notificación y devuelves un 202. La petición termina. El proceso puede morirse tranquilo. Y el presupuesto de pasos sigue siendo tuyo, exactamente igual que en el agentic loop en producción.

    Esto es, con otros nombres, lo que hacen los frameworks. LangGraph lo llama interrupts: la función interrupt() congela el grafo, el checkpointer persiste el estado y se reanuda con new Command({ resume }) sobre el mismo thread_id.

    El AI SDK trae tool approvals, y aquí hay que fijarse en dos cosas. La primera, dónde se declara: toolApproval no va dentro de la tool, va como opción de generateText, streamText o del ToolLoopAgent, en un mapa indexado por nombre de herramienta. Cada política recibe el input tipado y devuelve 'user-approval', undefined si no aplica, o un { type: 'denied', reason }. La segunda, la versión: esa API llegó en la v7 y no existe en la 5, que es contra la que está escrito el código de este post.

    Enfoque Cómo se pausa Dónde vive el estado Qué te sigue tocando a ti
    Propio (este post) El loop devuelve awaiting_approval y la API un 202 Tabla agent_runs en Postgres, columna jsonb Todo, pero sin sorpresas
    LangGraph interrupt() congela el grafo en el nodo Checkpointer (memoria, Postgres, SQLite) Caducidad, idempotencia y el mensaje de rechazo
    AI SDK v7 toolApproval deja la tool pendiente Lo persistes tú Dónde guardas los mensajes y qué haces al reanudar

    Úsalos si te encajan — pero ninguno responde por ti dónde vive el checkpoint, quién limpia los que nadie aprobó y qué le cuentas al modelo cuando la respuesta es que no. Si prefieres modelar todo esto como estados explícitos, el enfoque de grafo de estados frente al loop clásico resuelve bastante bien la parte de la máquina de estados.

    Los ejemplos de este post están escritos y verificados en septiembre de 2026 contra AI SDK 5 y LangGraph JS; si estás en AI SDK 4.x, los argumentos viajan en call.args y el resultado en result en vez de output.


    Qué debe ver el humano: sin datos, la aprobación es una firma

    «El agente quiere borrar 1.204 clientes. ¿Apruebas?» no es una pregunta. Es un trámite.

    Nadie puede aprobar eso de verdad, porque no hay nada que evaluar. Y si no hay nada que evaluar, el humano no está decidiendo: está firmando.

    El payload de aprobación tiene que llevar lo suficiente para decir que no:

    // preview.ts
    export interface ApprovalPreview {
      title: string;          // "Reembolsar 38 pagos — 4.120,00 €"
      rationale: string;      // por qué el agente cree que hay que hacerlo
      impact: string[];       // efectos concretos, contados
      payload: unknown;       // exactamente lo que se va a ejecutar, sin resumir
      sample: unknown[];      // 5 filas afectadas, para oler el error
      reversible: boolean;
      precondition: { name: string; value: string | number }; // se revalida al reanudar
    }
    

    Cuatro reglas, y la primera es innegociable.

    El preview lo genera tu código, no el modelo. Si el resumen que lee el humano lo escribe el mismo LLM cuya acción estás supervisando, has montado un control donde el vigilado redacta el informe. Y si esa tool call llegó por una inyección de prompt en un email que el agente leyó, el resumen viene envenenado igual — el mismo problema que trato en tácticas defensivas contra inyección de prompts. El texto lo compone tu función a partir de los argumentos y de una consulta real a tu base de datos.

    Números, no adverbios. «Varios registros» no se puede aprobar. «1.204 registros, de los cuales 6 tienen facturas emitidas este trimestre» se aprueba o se rechaza en cuatro segundos.

    Una muestra. Cinco filas de las que van a cambiar. Es donde el humano detecta que el filtro estaba mal, y le cuesta una consulta barata a tu API.

    Una precondición y una caducidad. Guarda el número que justificaba la acción — 38 pagos fallidos — y vuelve a comprobarlo al reanudar. Una aprobación de hace seis horas puede estar aprobando un mundo que ya no existe: si ahora hay 41, no ejecutas, vuelves a preguntar. Exactamente el fallo que le costó tres reembolsos de más a mi cliente.


    Idempotencia: reanudar el agente sin ejecutar la acción dos veces

    Persistir el estado abre la puerta al segundo problema del día: el botón se puede pulsar dos veces. Y se pulsa. El humano da doble clic, el webhook de Slack reintenta porque tu 200 tardó, alguien reenvía el enlace por WhatsApp.

    Necesitas dos capas, porque cada una tapa un agujero distinto.

    La primera es un compare-and-swap en la base de datos. No leas el checkpoint y luego lo actualices: reclámalo en una sola sentencia atómica.

    UPDATE agent_runs
       SET status = 'resuming', resolved_at = now()
     WHERE run_id = $1
       AND approval_id = $2
       AND status = 'awaiting_approval'
    RETURNING checkpoint;
    

    Si devuelve cero filas, alguien te ganó la carrera. No es un error: es el sistema funcionando. Devuelves un 200 idempotente y te callas.

    Y ponle un timeout también al estado resuming: si el proceso se muere justo después de reclamar el checkpoint, esa fila se queda ahí para siempre y el job de caducidad, que solo mira awaiting_approval, no la va a rescatar nunca.

    // resume.ts
    export type Decision =
      | { type: "approved"; approvedBy: string }
      | { type: "approved_with_changes"; approvedBy: string; input: unknown }
      | { type: "rejected"; approvedBy: string; reason: string };
    
    export async function resumeRun(
      runId: string,
      approvalId: string,
      decision: Decision,
    ): Promise<AgentOutcome> {
      const claimed = await checkpoints.claim(runId, approvalId);
      if (!claimed) return { status: "already_resolved" };
    
      const { messages, partialResults, approval, stepsUsed } = claimed;
    
      if (decision.type === "rejected") {
        const denial = toolResult(approval, buildDenialResult(approval, decision));
        return runAgent(runId, [...messages, { role: "tool", content: [...partialResults, denial] }], stepsUsed);
      }
    
      const input =
        decision.type === "approved_with_changes" ? decision.input : approval.input;
    
      // El payload lleva horas en la base de datos y vuelve a entrar por la puerta.
      // Se valida otra vez contra el schema de la tool, como si viniera de fuera.
      const parsed = toolSchemas[approval.toolName as keyof typeof toolSchemas].inputSchema.parse(input);
    
      // La precondición que vio el humano tiene que seguir siendo verdad
      const still = await checkPrecondition(approval.preview.precondition);
      if (!still.ok) return requestApprovalAgain(runId, approval, still.current);
    
      // idempotencyKey = approvalId: si esto se ejecuta dos veces, el proveedor
      // devuelve el mismo resultado en lugar de mover el dinero otra vez
      const output = await executeTool({ ...approval, input: parsed }, { idempotencyKey: approvalId });
    
      const result = toolResult(approval, {
        ok: true,
        approvedBy: decision.approvedBy,
        executedInput: parsed, // lo que se ejecutó de verdad, no lo que se pidió
        data: output,
      });
    
      return runAgent(runId, [...messages, { role: "tool", content: [...partialResults, result] }], stepsUsed);
    }
    

    La segunda capa es la idempotencia aguas abajo. El compare-and-swap te protege del doble clic, pero no del proceso que se cae justo entre mover el dinero y escribir en la base de datos que lo movió. Para eso la acción tiene que ser idempotente en el otro extremo: la clave de idempotencia de Stripe, una restricción única en la tabla de efectos, un INSERT ... ON CONFLICT DO NOTHING. Usa el approvalId como clave y el reintento devuelve el mismo resultado en lugar de un segundo reembolso. Y no es casualidad que el expiresAt de la aprobación sean 24 horas: Stripe purga las claves de idempotencia a partir de las 24 horas de antigüedad, así que una aprobación que sobreviviera a esa ventana perdería justo la red que la protegía.

    Y ojo con el parse de esa función, que parece decorativo y no lo es. Ese payload salió de tu proceso hace ocho horas, ha dormido en una base de datos y vuelve por un endpoint público. Tratarlo como dato de confianza porque «lo generamos nosotros» es el tipo de suposición que valido siempre en frontera, con el enfoque de schemas del curso de Zod para TypeScript.


    Qué le devuelves al modelo cuando el humano dice que no

    Aquí es donde se cae la mitad de las implementaciones que he revisado.

    Devuelven esto:

    { "error": "denied" }
    

    Y el modelo hace lo que hace un modelo ante una puerta cerrada: buscar otra. Reintenta con parámetros distintos, parte el borrado en dos llamadas de 600 registros, o directamente redacta una respuesta final diciendo que los reembolsos se han procesado correctamente. Un rechazo que parece un fallo técnico se lee como un fallo técnico.

    El resultado de una tool es prompt. Escríbelo como tal:

    {
      "ok": false,
      "approvalDenied": true,
      "toolName": "refundPayments",
      "deniedBy": "marta@cliente.com",
      "reason": "12 de los 38 pagos son de un lote que ya se reembolsó manualmente el viernes.",
      "instruction": "Un humano ha rechazado esta acción. No vuelvas a llamar a \"refundPayments\" en esta conversación, ni con otros parámetros, ni en lotes más pequeños, ni a través de otra herramienta. No intentes conseguir el mismo efecto por otra vía. Explica al usuario qué ibas a hacer, cuál fue el motivo del rechazo, y termina el turno sin ejecutar nada más."
    }
    

    Cuatro ingredientes y los cuatro son necesarios:

    1. Que fue una persona, no un error de red. Cambia por completo la interpretación.
    2. El motivo en lenguaje humano. Es información nueva y real que el modelo no tenía.
    3. La prohibición de buscar rutas alternativas, dicha de forma explícita. Sin esta línea, el modelo trocea la acción y lo vuelve a intentar.
    4. Qué hacer ahora. Terminar y reportar. Si no le das salida, se inventa una.

    Y existe un tercer estado que casi nadie modela: aprobado con cambios. El humano no rechaza, edita — baja el reembolso a 26 pagos y aprueba. En ese caso, el resultado que devuelves debe llevar el executedInput real, porque si el modelo cree que se ejecutaron 38 va a escribirle al usuario que se reembolsaron 38. La verdad de lo que pasó viaja en el resultado de la tool o no viaja.

    Añade también una línea al system prompt describiendo el protocolo: «si el resultado de una herramienta trae approvalDenied: true, esa acción está vetada para el resto de la conversación; sigue el campo instruction y no busques alternativas». El modelo obedece bastante bien cuando la instrucción es específica y llega en el momento en que toma la decisión.


    Cómo implementar human in the loop hoy en tu agente

    No montes el sistema entero. Coge la herramienta más peligrosa de tu agente — todos sabemos cuál es — y hazle tres cosas esta tarde.

    Sácala del execute automático. Haz que tu loop, al llegar a ella, guarde los mensajes y la llamada pendiente en una tabla y devuelva un 202. Y escribe el resultado de rechazo como si fuera un prompt, porque lo es.

    Con eso ya tienes lo importante: un agente que puede quedarse esperando sin estar vivo.

    La idea de fondo es la misma que con cualquier mecanismo de defensa en un agente: si no le hablas al modelo, no lo estás controlando, solo lo estás frenando. Y un modelo frenado sin explicaciones busca la puerta de al lado.

    Decidir todo esto antes de escribir el código — qué es irreversible, quién aprueba, qué pasa cuando la respuesta es no — es el método que enseño en el curso Construye con IA: de la idea al producto.

    Y si prefieres verlo funcionando antes que leerlo, en Dominicode Labs estamos rodando estos checkpoints sobre proyectos reales, con la tabla de aprobaciones y el audit trail puestos.


    Preguntas frecuentes

    ¿Qué es human in the loop en un agente de IA?

    Human in the loop (HITL) es el patrón por el que un agente de IA detiene su ejecución antes de realizar una acción irreversible y solo continúa cuando una persona la aprueba, la edita o la rechaza. En una implementación correcta la pausa no es un await: el agente termina su ejecución guardando un checkpoint con los mensajes y la llamada pendiente, y una ejecución nueva, disparada por la decisión del humano, lo reanuda desde ahí. Aplica a mover dinero, enviar emails masivos, borrar registros, desplegar y publicar.

    ¿Puedo implementar human in the loop con un simple await hasta que el humano responda?

    Solo si el humano contesta en segundos y tu proceso es de larga vida. En cuanto la aprobación puede tardar minutos, el await deja de ser una espera y pasa a ser una apuesta: un despliegue, un timeout de la función serverless o un reinicio del contenedor se llevan por delante todo el estado del agente. Además pagas RAM y una conexión abierta por cada aprobación pendiente. La alternativa correcta es terminar la ejecución, persistir el checkpoint y reanudar con una ejecución nueva.

    ¿Qué acciones de un agente deben requerir aprobación humana?

    Las irreversibles, y dentro de ellas solo las que superan un umbral. Todo lo de solo lectura va sin aprobación siempre; lo reversible va sin aprobación pero con registro y con una forma de deshacer; lo irreversible — mover dinero, enviar emails, borrar, desplegar, publicar — pide permiso. El umbral se calcula sobre los argumentos, no sobre la herramienta: un reembolso de 3 € y uno de 4.000 € usan la misma tool y no tienen el mismo riesgo.

    ¿Dónde guardo el estado del agente mientras espera la aprobación?

    En un almacén duradero fuera del proceso: una tabla en Postgres con run_id, approval_id, status y una columna jsonb con los mensajes, los resultados parciales del turno y la llamada pendiente. Redis sirve si tiene persistencia y si el TTL es mayor que el tiempo máximo de aprobación, pero para acciones que mueven dinero quieres una tabla auditable donde consultar meses después quién aprobó qué.

    ¿Qué pasa si nadie aprueba nunca la acción del agente?

    Que se te llena la base de datos de agentes zombis, así que la caducidad forma parte del diseño. Pon un expiresAt en cada aprobación, y un job que cierre las vencidas devolviendo al modelo un resultado de expiración con la misma estructura que el rechazo — para que el agente pueda cerrar la conversación explicando qué quedó sin hacer. Si un tipo de aprobación caduca de forma sistemática, el problema no es el TTL: es que estás pidiendo permiso para algo que a nadie le importa.

    ¿Cómo evito que el agente ejecute dos veces si la aprobación llega duplicada?

    Con dos capas. La primera es reclamar el checkpoint con un UPDATE ... WHERE status = 'awaiting_approval' RETURNING, en una sola sentencia atómica: si devuelve cero filas, otro ya lo reanudó y no ejecutas nada. La segunda es hacer idempotente la acción en el otro extremo, usando el approvalId como clave de idempotencia o como restricción única, porque el compare-and-swap no te salva si el proceso se cae justo después de mover el dinero y antes de registrarlo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Pi no tiene sistema de permisos, y te lo dice en su propio README

    Pi no tiene sistema de permisos, y te lo dice en su propio README

    Instalas Pi con un npm install -g, lo lanzas en tu proyecto y funciona.

    Y funciona muy bien. Es el harness de código abierto más interesante que hay ahora mismo: minimalista a propósito, cuatro herramientas activas por defecto —read, write, edit, bash—, y un core tan pequeño que te lo lees entero en una tarde. Ya conté por qué es el mejor ejemplo para entender la anatomía de un harness en qué es un agent harness.

    Este post va de la frase que hay en su README y que casi nadie cita:

    "Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it."

    Traducido: Pi no tiene sistema de permisos. Corre con los tuyos.

    Y lo importante es que no es un descuido ni una versión temprana. Es coherente con la tesis del proyecto: el core no engorda, y lo que otros traen de fábrica aquí lo montas tú. El propio README te dice a continuación qué montar, con tres patrones documentados.

    Vamos con lo que estás aceptando y con cómo ponerle un límite.


    Qué significa exactamente "corre con tus permisos"

    No es una advertencia genérica. Significa, literalmente, que el proceso puede hacer todo lo que puedes hacer tú desde esa terminal:

    • Leer cualquier archivo de tu usuario. Tus claves en ~/.ssh, los .env de todos tus proyectos, las credenciales de tu CLI de nube, tus tokens de sesión.
    • Escribir y borrar en cualquier sitio, no solo en el proyecto donde lo lanzaste.
    • Ejecutar cualquier comando con bash, incluidos git push, curl a donde sea, o un npm install de un paquete que no has revisado.
    • Salir a la red sin restricción.

    Y la parte que más se subestima: el agente no tiene que querer hacer nada de eso para que pase. Basta con que lo lea en algún sitio. Una dependencia con instrucciones metidas en el README, la salida de una herramienta, una issue de GitHub que le pides que resuma. Eso es inyección indirecta de prompts, y lo desarrollé entero en cómo proteger tus agentes de la inyección indirecta.

    Con un agente sin capa de permisos, la distancia entre "leyó algo raro" y "ejecutó algo raro" es cero.


    Las dos formas de ponerle un límite

    La documentación oficial plantea la decisión con una claridad que se agradece. Solo hay dos opciones:

    1. Meter el proceso pi entero dentro de un entorno aislado.
    2. Dejar pi en tu máquina y enrutar la ejecución de las herramientas hacia un entorno aislado.

    La diferencia no es cosmética y decide dónde acaban tus credenciales. Si metes el proceso entero en un contenedor, las claves de tu proveedor de IA entran con él. Si dejas el proceso fuera y solo enrutas las herramientas, la autenticación se queda en tu host y lo que viaja al entorno aislado son las operaciones.


    Los tres patrones documentados

    Patrón Qué se aísla Cuándo Lo que cuesta
    Gondolin Herramientas integradas y comandos ! Quieres la micro-VM pero la auth en tu host Node ≥ 23.6.0 y QEMU
    Docker plano El proceso pi entero Aislamiento local simple Tus claves de API entran en el contenedor
    OpenShell El proceso entero, con políticas Sandbox gestionado, local o remoto Necesita un gateway activo

    Gondolin: la micro-VM que se traga las herramientas

    Gondolin es una micro-VM de Linux local. La extensión de ejemplo deja pi corriendo en tu máquina y redirige las herramientas integradas hacia la VM, sobrescribiendo read, write, edit, bash, grep, find y ls. Los comandos ! que escribes tú también van dentro.

    cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
    cd ~/.pi/agent/extensions/gondolin
    npm install --ignore-scripts
    
    cd /ruta/a/tu/proyecto
    pi -e ~/.pi/agent/extensions/gondolin
    

    Monta tu directorio actual en /workspace dentro de la VM. Es el patrón con mejor relación aislamiento/comodidad: tu autenticación no sale del host.

    Con una advertencia que conviene decir en voz alta: Gondolin se describe a sí mismo como experimental («Experimental Linux microvm setup with a TypeScript Control Plane as Agent Sandbox») y va por unas 2.000 estrellas frente a las más de 96.000 de Pi. Es el patrón que mejor encaja conceptualmente, pero es la pieza más joven de las tres.

    Docker plano: el más simple, con una letra pequeña

    Metes todo el proceso en un contenedor:

    docker run --rm -it \
      -e ANTHROPIC_API_KEY \
      -v "$PWD:/workspace" \
      -v pi-agent-home:/root/.pi/agent \
      pi-sandbox
    

    Fíjate en el -e ANTHROPIC_API_KEY: la clave entra. Y fíjate en el volumen con nombre para /root/.pi/agent — está ahí a propósito, porque la documentación avisa de que montar tu ~/.pi/agent del host expone tus credenciales y tus sesiones al contenedor. Si montas ese directorio por comodidad, te has saltado media barrera.

    OpenShell: cuando necesitas políticas de verdad

    OpenShell es un sandbox con control de políticas sobre sistema de archivos, procesos, red, credenciales e inferencia. Corre a través de un gateway local (Docker, Podman o una VM) o de uno remoto sobre Kubernetes. Todo —herramientas integradas, comandos ! y herramientas de extensiones— se ejecuta dentro del límite.

    Es el más pesado de montar y el único que te da políticas explícitas. Si esto va a tocar código de un cliente, es el que te van a pedir.


    Tres cosas de las que el aislamiento NO te salva

    Aquí es donde se cae la sensación de seguridad, y las tres salen de la propia documentación.

    1. Tu proyecto sigue siendo escribible. En Gondolin y en Docker, tu directorio actual se monta en /workspace y los cambios escriben directamente en tus archivos del host. Eso es lo que quieres —para eso lo usas— pero significa que el contenedor protege el resto de tu máquina, no tu código. Un borrado desafortunado dentro de /workspace es un borrado en tu disco. La red de seguridad de tu proyecto sigue siendo Git, no el sandbox.

    2. Tus propias extensiones se quedan fuera. Esta es la fuga más sutil y está dicha con todas las letras: "las extensiones se ejecutan allí donde se ejecuta el proceso pi". Si usas el patrón de enrutado con pi en el host, las herramientas de tus extensiones personalizadas siguen corriendo en tu máquina salvo que ellas también deleguen sus operaciones. Montas la micro-VM, respiras tranquilo, y la extensión que escribiste el mes pasado sigue teniendo acceso directo a tu disco.

    3. Las credenciales del proveedor. En el patrón Docker entran en el contenedor por diseño. Si lo que te preocupa es que se filtre la clave de tu API, ese patrón no es el tuyo: es Gondolin.


    Cómo elegir en treinta segundos

    • Vas a dejarlo trabajar solo, con tu código personal: Gondolin. La auth se queda fuera y las herramientas dentro.
    • Quieres el aislamiento más simple y la clave de API te da igual (una de proyecto, con límite de gasto): Docker.
    • Es código de cliente, o tienes que justificar controles ante alguien: OpenShell.
    • Estás mirando el diff de cada paso, en un repo tuyo, con todo commiteado: puedes ir sin nada. Pero que sea una decisión, no un descuido.

    Y una que aplica a los cuatro casos: usa una clave de API distinta y con límite de gasto para el agente. No la misma que tu producción.

    Si el aislamiento con contenedores es terreno nuevo para ti, la base está en entornos de desarrollo reproducibles con Docker y Dev Containers, y el caso concreto de encapsular la ejecución de un agente lo conté en Docker sandboxing para ejecutar código de IA.


    Esto no va solo de Pi

    Lo que hace distinto a Pi no es que corra con tus permisos. Es que lo pone por escrito en la primera pantalla del repositorio, y te documenta tres formas de arreglarlo.

    La pregunta útil no es "¿es Pi seguro?". Es: de los agentes CLI que tienes instalados ahora mismo, ¿cuántos te han dicho con esta claridad qué pueden tocar? La mayoría no tiene esa sección porque no le interesa tenerla, no porque el problema no exista.

    Ese es el criterio con el que conviene mirar cualquier herramienta agéntica que instales: no cuántas capacidades trae, sino qué te cuenta sobre sus límites. Un proyecto que te documenta cómo encerrarlo te está respetando más que uno que no menciona el tema.

    Delimitar el alcance del trabajo antes de lanzar al agente reduce mucho la superficie de todo esto, y es la metodología que tienes en el libro de Spec-Driven Development. El flujo completo con herramientas CLI agénticas lo enseño en el curso Construye con IA: de la idea al producto con Claude Code.

    En Dominicode Labs comparto las configuraciones de aislamiento que uso de verdad para dejar agentes trabajando sin vigilarlos.

    Un agente sin permisos no es un agente inseguro. Es un agente que te ha dejado a ti la decisión, y te ha dicho dónde está el interruptor.


    Preguntas frecuentes

    ¿Pi es inseguro por no tener sistema de permisos?

    Es una decisión de diseño coherente con su minimalismo, no un fallo: el core no incorpora lo que puedes montar fuera. Lo que sí es imprudente es usarlo sin aislamiento en una máquina con credenciales, porque corre con todos los permisos del usuario que lo lanzó. El propio proyecto documenta tres patrones para ponerle límites.

    ¿Cuál de los tres patrones de aislamiento elijo?

    Gondolin si quieres que tus credenciales de proveedor se queden en el host y solo viajen las operaciones a la micro-VM. Docker si buscas el límite más simple y no te importa que la clave de API entre en el contenedor. OpenShell si necesitas políticas explícitas sobre archivos, procesos, red y credenciales, normalmente porque tienes que justificarlas ante un cliente o un equipo de seguridad.

    Si aíslo el agente en un contenedor, ¿mi código está a salvo?

    No del todo. En Gondolin y en Docker tu directorio de trabajo se monta en /workspace y lo que se escribe ahí llega a tus archivos reales — tiene que ser así para que el agente sirva de algo. El aislamiento protege el resto de la máquina: tus claves, otros proyectos, tu red. Para el código, tu red de seguridad sigue siendo Git y tener todo commiteado antes de lanzarlo.

    ¿Mis extensiones personalizadas también quedan aisladas?

    No automáticamente, y es la fuga más fácil de pasar por alto. Las extensiones se ejecutan donde se ejecuta el proceso pi: si usas el patrón de enrutado con pi en el host, las herramientas de tus extensiones siguen corriendo en tu máquina salvo que las escribas para delegar sus operaciones al entorno aislado.

    ¿Cuántas herramientas trae Pi realmente?

    Cuatro activas por defecto —read, write, edit y bash—, que son las que sostienen la tesis del proyecto. Hay algunas más disponibles: la extensión de Gondolin, por ejemplo, sobrescribe read, write, edit, bash, grep, find y ls. La cifra que importa no es cuántas existen, sino cuántas van al contexto por defecto.

  • Guardrails para agentes: probé la blocklist típica y pasan 16 de 20

    Guardrails para agentes: probé la blocklist típica y pasan 16 de 20

    La primera semana que le das a un agente acceso a tu terminal te sientes invencible.

    Le pides que instale una dependencia, ejecute los tests, cree una rama y arregle un bug. Y lo hace, mientras tú haces otra cosa.

    Después llega la pregunta incómoda: ¿qué pasa exactamente si se equivoca?

    Casi todo el mundo responde igual. Ha metido en el System Prompt una frase del tipo "por favor, nunca ejecutes comandos destructivos", y con eso duerme tranquilo. Eso no es una barrera: un modelo es probabilístico y esa frase compite con todo lo demás que hay en el contexto. Que el LLM no puede ser su propia barrera de seguridad ya lo desarrollé en guardrails y tácticas defensivas contra inyección de prompts, así que aquí lo doy por sabido.

    Este post va del siguiente paso, el que casi nadie audita: el que sí escribió código de defensa y cree que con eso está cubierto.

    Porque hay un guardrail concreto que escribimos todos, que parece serio, que da mucha tranquilidad y que no aguanta ni una reordenación de flags. Vamos a romperlo con un test que puedes ejecutar tú.


    El guardrail que todos escribimos

    Es este, con pequeñas variaciones. Una lista de patrones peligrosos y un interceptor delante del ejecutor de herramientas:

    const FORBIDDEN_PATTERNS = [
      /rm\s+(-rf|-fr|\*)/i,
      /git\s+push\s+.*(--force|-f)/i,
      /git\s+reset\s+--hard/i,
      /drop\s+table/i,
      /chmod\s+777/i,
      /curl\s+.*\|\s*(bash|sh)/i,
    ];
    
    const blocked = (cmd: string) => FORBIDDEN_PATTERNS.some(p => p.test(cmd));
    

    Tiene buena pinta. Cubre el rm -rf de los titulares, el force push, el DROP TABLE y el clásico curl | bash.

    Ahora vamos a medirlo.


    El test: 16 de 20 comandos destructivos pasan

    Le pasé a ese filtro 20 comandos que ningún agente debería poder ejecutar sobre tu máquina. Este es el resultado completo:

    Comando ¿Lo detiene?
    rm -rf / Bloqueado
    rm -r -f / Pasa
    rm -Rf ~/proyecto Bloqueado
    rm --recursive --force / Pasa
    rm -f -r . Pasa
    find . -delete Pasa
    find . -exec rm {} + Pasa
    git clean -fdx Pasa
    > package.json Pasa
    cat /dev/null > .env Pasa
    dd if=/dev/zero of=/dev/sda Pasa
    git reset --hard HEAD~5 Bloqueado
    DROP TABLE users Bloqueado
    drop/**/table users Pasa
    TRUNCATE TABLE users Pasa
    DELETE FROM users Pasa
    mv proyecto /dev/null Pasa
    $(echo rm) -rf / Pasa
    npm run deploy:prod Pasa
    chmod -R 777 / Pasa

    Cuatro bloqueados, dieciséis dentro. Y fíjate en la segunda fila, porque resume el problema entero:

    rm -rf / está bloqueado. rm -r -f / pasa.

    Es el mismo comando. Borra exactamente lo mismo. Lo único que cambia es que los flags van separados, y el modelo no necesita saber que existe un filtro para escribirlo así: es una forma perfectamente normal de escribir ese comando.

    La última fila es igual de reveladora. El patrón /chmod\s+777/ espera que el 777 venga justo detrás de chmod, así que chmod -R 777 / —que es peor, porque es recursivo— no lo toca.

    Aquí tienes el test entero para que lo corras contra tu propia lista antes de seguir leyendo:

    const destructivos = [
      "rm -rf /", "rm -r -f /", "rm -Rf ~/proyecto", "rm --recursive --force /",
      "rm -f -r .", "find . -delete", "find . -exec rm {} +", "git clean -fdx",
      "> package.json", "cat /dev/null > .env", "dd if=/dev/zero of=/dev/sda",
      "git reset --hard HEAD~5", "DROP  TABLE users", "drop/**/table users",
      "TRUNCATE TABLE users", "DELETE FROM users", "mv proyecto /dev/null",
      "$(echo rm) -rf /", "npm run deploy:prod", "chmod -R 777 /",
    ];
    
    const pasan = destructivos.filter(c => !blocked(c));
    console.log(`${pasan.length}/${destructivos.length} pasan el filtro`);
    console.log(pasan);
    

    Y hay un segundo efecto, menos grave pero muy revelador: el patrón del force push bloquea git push --force-with-lease, que es precisamente la variante segura. Una blocklist no solo deja pasar lo peligroso; también prohíbe cosas correctas, y eso es lo que te acaba empujando a desactivarla.


    El fallo no son los patrones. Es la arquitectura

    La tentación, al ver esa tabla, es añadir patrones. Meter -r -f, meter find, meter TRUNCATE.

    No sirve. Puedes pasarte una tarde ampliando la lista y mañana el agente encontrará la forma número veintidós, porque una shell tiene infinitas maneras de expresar la misma destrucción: flags separados, flags largos, alias, sustitución de comandos, redirecciones, herramientas distintas que hacen lo mismo.

    Esto tiene nombre desde hace décadas en seguridad: enumerating badness, enumerar lo malo. Y siempre pierde, porque el conjunto de lo peligroso es infinito y el de lo permitido es finito.

    La inversión es la solución completa:

      BLOCKLIST              ALLOWLIST
      ─────────              ─────────
      permite por defecto    deniega por defecto
      enumera lo malo        enumera lo bueno
      conjunto infinito      conjunto finito
      falla abierta          falla cerrada
    

    Con una allowlist, el comando número veintidós que no habías previsto no se ejecuta, porque no está en la lista. Ese es el único diseño en el que un olvido tuyo no se convierte en un incidente.


    El chequeo de rutas también se cae

    El mismo middleware suele traer una validación de rutas parecida a esta:

    if (path.startsWith("/") || path.includes("..")) return BLOQUEADO;
    

    Falla en las dos direcciones. Estas rutas pasan:

    • C:\Windows\System32 — una ruta absoluta de Windows no empieza por /.
    • ~/.ssh/id_rsa — la expande la shell después de tu comprobación.

    Y a la vez bloquea src/../lib/x.ts, que es una ruta legítima dentro del proyecto.

    El arreglo es no razonar sobre el texto de la ruta, sino resolverla y comprobar dónde acaba:

    import path from "node:path";
    
    const ROOT = path.resolve(process.env.AGENT_WORKSPACE!);
    
    export function dentroDelWorkspace(candidata: string): boolean {
      const destino = path.resolve(ROOT, candidata);
      return destino === ROOT || destino.startsWith(ROOT + path.sep);
    }
    

    Con eso, ../../etc/passwd y /etc/passwd quedan fuera —los dos resuelven a un destino que no cuelga de ROOT— mientras que src/../lib/x.ts entra sin problema. La comprobación deja de depender de cómo esté escrita la ruta.

    Un aviso: si tu agente puede crear enlaces simbólicos, resuélvelos también (fs.realpath) antes de comparar. Un symlink dentro del workspace apuntando fuera se salta la comprobación de arriba.


    Las 4 capas que sí sostienen la capa de ejecución

    En orden, de más a menos importante.

    1. Allowlist de comandos, denegar por defecto

    Define qué puede ejecutar el agente, no qué no puede. Empieza por lo que de verdad necesita a diario —tests, linter, build, git status, git diff— y ve añadiendo cuando algo se bloquee de forma legítima.

    La forma práctica de arrancar: registra durante una semana todo lo que el agente intenta ejecutar sin bloquear nada, y monta la allowlist a partir de esa lista real. Casi siempre son menos de treinta comandos.

    Si usas un agente CLI, esto normalmente ya existe en su configuración: reglas de permiso allow / deny / ask y modos de permisos. Revisa el tuyo con una pregunta concreta: ¿hay alguna regla comodín tipo Bash(*) que anule a todas las demás? Si la hay, tu allowlist es decorativa.

    2. Confinamiento por ruta resuelta

    El agente trabaja dentro de un directorio y solo dentro de él. Con la función de arriba, y aplicada a todas las herramientas que tocan disco: leer, escribir, mover y borrar. Confinar solo la escritura deja abierta la exfiltración de .env y de tus claves.

    3. Puerta humana para lo irreversible

    Lo que no se puede deshacer no se automatiza: git push, migraciones, escrituras en base de datos de producción, despliegues, borrados. El criterio no es "peligroso" sino "¿puedo revertirlo en un minuto?". Es el mismo principio de mínimo privilegio que desarrollé al hablar de inyección indirecta de prompts en agentes, aplicado aquí a la shell.

    Montar esa puerta bien tiene su propia arquitectura —clasificar las tools por riesgo, persistir el estado mientras se espera y no ejecutar dos veces al reanudar—, y la desarrollo en arquitectura human in the loop en TypeScript.

    4. Aislamiento: que el radio del fallo sea pequeño

    Las tres capas anteriores fallan alguna vez. La cuarta decide cuánto duele.

    Dale a cada tarea su propia rama y su propio directorio de trabajo, y un contenedor cuando la tarea toque dependencias o servicios. Si algo sale mal, borras el directorio y no has perdido nada. Cómo montar el aislamiento fuerte con contenedores lo detallé en Docker sandboxing para ejecutar código de IA.


    El middleware corregido

    Juntando las piezas, el interceptor queda así:

    import path from "node:path";
    
    const COMANDOS_PERMITIDOS = new Set([
      "npm", "pnpm", "bun", "node", "tsc", "eslint", "prettier", "vitest", "jest",
    ]);
    
    const SUBCOMANDOS_GIT = new Set(["status", "diff", "log", "add", "commit", "branch", "checkout"]);
    
    const IRREVERSIBLES = new Set(["push", "reset", "clean", "rebase"]);
    
    const ROOT = path.resolve(process.env.AGENT_WORKSPACE!);
    
    type Decision =
      | { tipo: "ejecutar" }
      | { tipo: "preguntar"; motivo: string }
      | { tipo: "denegar"; motivo: string };
    
    export function decidir(argv: string[], rutas: string[] = []): Decision {
      for (const r of rutas) {
        const destino = path.resolve(ROOT, r);
        if (destino !== ROOT && !destino.startsWith(ROOT + path.sep)) {
          return { tipo: "denegar", motivo: `La ruta "${r}" queda fuera del workspace.` };
        }
      }
    
      const [binario, sub] = argv;
    
      if (binario === "git") {
        if (IRREVERSIBLES.has(sub)) return { tipo: "preguntar", motivo: `git ${sub} no es reversible.` };
        if (SUBCOMANDOS_GIT.has(sub)) return { tipo: "ejecutar" };
        return { tipo: "denegar", motivo: `git ${sub} no está en la allowlist.` };
      }
    
      if (COMANDOS_PERMITIDOS.has(binario)) return { tipo: "ejecutar" };
    
      return { tipo: "denegar", motivo: `"${binario}" no está en la allowlist.` };
    }
    

    Tres detalles que hacen que esto funcione y la versión anterior no:

    Recibe argv, no un string. Nada de analizar una línea de shell con expresiones regulares. Si construyes el comando como array de argumentos y lo ejecutas sin shell (execFile en lugar de exec), desaparecen de golpe la sustitución de comandos, las redirecciones y el encadenado con ; o &&. La mitad de las evasiones de la tabla de arriba dejan de existir.

    Devuelve tres estados, no un booleano. ejecutar, preguntar y denegar. Sin el estado intermedio acabas ampliando la allowlist con cosas irreversibles solo para no tener que confirmar cada vez.

    Deniega por defecto. El return final es una denegación. Lo que no previste no se ejecuta.

    Y cuando bloquees, devuélvele al agente el motivo en texto, no una excepción: el modelo lo lee y busca otra vía en lugar de dejar la tarea a medias.

    Para los parámetros estructurados que llegan a una herramienta o a la base de datos, la validación de schema con Zod es la pieza que cierra el círculo, y los patrones de contrato están en el curso de Zod para TypeScript. El criterio general de dónde poner las validaciones —y dónde no— lo tienes en programación defensiva en TypeScript.


    El guardrail más barato: acotar antes de empezar

    Todo lo anterior actúa cuando el agente ya está trabajando. Es más barato reducir lo que puede intentar.

    Cuando escribes un spec.md que fija qué archivos entran en la tarea y qué queda fuera, el agente deja de tener motivos para acercarse al resto del repositorio. No sustituye a los guardrails —una especificación no es un control de seguridad— pero baja mucho la frecuencia con la que se activan.

    La metodología completa está en el libro de Spec-Driven Development, y el flujo práctico con agentes CLI en el curso Construye con IA: de la idea al producto con Claude Code.


    Checklist para esta semana

    1. Corre el test de arriba contra tu propia blocklist. Diez minutos. Si pasa más de la mitad, ya sabes en qué punto estás.
    2. Busca el comodín. Abre la configuración de permisos de tu agente y comprueba si hay una regla que permita todo. Suele estar puesta desde el primer día y olvidada.
    3. Ejecuta sin shell. Cambia exec por execFile con argv. Es el cambio con mejor relación esfuerzo/resultado de toda la lista.
    4. Confina por ruta resuelta, en lectura y en escritura.

    En Dominicode Labs revisamos arquitecturas agénticas reales y compartimos las configuraciones de permisos que aguantan en producción.

    La autonomía de verdad no es darle libertad total al modelo. Es construirle un sitio donde equivocarse salga barato.


    Preguntas frecuentes

    ¿Por qué una lista de comandos prohibidos no basta para proteger a un agente?

    Porque enumera un conjunto infinito. Una shell puede expresar la misma acción destructiva de muchas formas —flags separados, flags largos, otra herramienta que hace lo mismo, sustitución de comandos— y tu lista solo cubre las que se te ocurrieron. En la prueba de este post, dieciséis de veinte comandos destructivos atraviesan una blocklist de aspecto razonable, incluido rm -r -f /, que es el mismo comando del ejemplo con los flags separados.

    ¿Cómo confino a un agente a la carpeta del proyecto?

    Resolviendo cada ruta con path.resolve() contra la raíz del workspace y comprobando que el resultado sigue colgando de esa raíz. No compruebes el texto de la ruta: startsWith("/") no detecta rutas absolutas de Windows ni el ~ que expande la shell, y includes("..") bloquea rutas internas legítimas. Si el agente puede crear symlinks, resuélvelos con fs.realpath antes de comparar.

    ¿Una allowlist no me va a estar frenando todo el rato?

    Los primeros días sí, y es la señal de que funciona. La forma de reducirlo es construirla con datos: registra una semana de comandos reales del agente y parte de ahí. Suelen ser menos de treinta. Y ten un estado intermedio de "preguntar" para lo irreversible: sin él acabarás metiendo en la allowlist cosas que no deberían estar solo para dejar de confirmar.

    ¿Necesito Docker para esto o me basta con una rama aislada?

    Depende de qué pueda romper la tarea. Una rama con su propio directorio de trabajo protege tu código y hace que tirar el trabajo cueste un segundo, pero comparte tu máquina, tus variables de entorno y tu red. Si la tarea instala dependencias, ejecuta código que no has leído o toca servicios, necesitas el aislamiento del contenedor.

    ¿Dónde pongo el guardrail: en la herramienta o en el agente?

    En la herramienta, siempre. Un control que vive en el prompt, en el nombre de la tool o en su descripción es una sugerencia que el modelo puede ignorar. El guardrail tiene que estar en el código que ejecuta la acción, de forma que ni siquiera un agente que decida saltárselo pueda hacerlo. Si el control se puede desactivar escribiendo texto, no es un control.

  • El Python que necesitas si construyes agentes en TypeScript

    El Python que necesitas si construyes agentes en TypeScript

    Tu producto está en TypeScript. Tu agente también.

    Y aun así, en algún momento vas a acabar escribiendo Python. No porque quieras cambiar de lenguaje, sino porque entre tus datos en crudo y el contexto que lee tu agente hace falta una capa que transforme lo uno en lo otro, y esa capa se escribe casi siempre en Python.

    No es un curso de Data Science. No hace falta álgebra lineal, ni estadística inferencial, ni un notebook con gráficos bonitos. Hacen falta cuatro operaciones, y con ellas se resuelve prácticamente todo lo que un agente necesita que le den masticado.

    Empiezo por el número que justifica el post entero.


    339.276 tokens contra 96

    Tengo una exportación de ventas: 40.000 filas, cinco columnas. La pregunta que quiero que responda el agente es sencilla: ¿qué curso conviene empujar el mes que viene?

    La forma perezosa es meterle el CSV entero en el contexto. Vamos a medir qué significa eso:

    import pandas as pd
    
    df = pd.read_csv("ventas.csv")          # 40.000 filas
    
    crudo = df.to_csv(index=False)
    
    resumen = (df.groupby("curso")
                 .agg(ventas=("precio", "size"),
                      ingresos=("precio", "sum"),
                      tasa_completado=("completado", "mean"))
                 .round(2)
                 .reset_index()
                 .sort_values("ingresos", ascending=False))
    
    compacto = resumen.to_json(orient="records")
    
    print(f"crudo   : {len(crudo):,} caracteres")
    print(f"resumen : {len(compacto):,} caracteres")
    print(f"factor  : {len(crudo)/len(compacto):,.0f}x")
    

    Resultado:

    crudo   : 1.357.104 caracteres
    resumen :       386 caracteres
    factor  :     3.516x
    

    A razón de unos cuatro caracteres por token, eso es pasar de ~339.000 tokens a ~96. Y esto es lo que ve el agente después de la reducción:

         curso  ventas  ingresos  tasa_completado
            IA    8792 439512.08             0.27
       Angular   12349 370346.51             0.48
    TypeScript    9522 190344.78             0.39
       Testing    5621 168573.79             0.55
          Node    3716  74282.84             0.34
    

    Cinco filas. Y ahí dentro ya está la respuesta, que además no es la obvia: IA es lo que más factura pero tiene la peor tasa de finalización (0,27), y Testing es lo que menos factura con la mejor con diferencia (0,55). Ese contraste no se ve en 40.000 filas ni lo va a encontrar un modelo leyéndolas.

    Esto no va de ahorrar dinero, aunque también. Va de que un modelo con 40.000 filas delante tiene 40.000 oportunidades de fijarse en lo que no toca. Reducir no es una optimización: es parte de la respuesta. Es exactamente el argumento de context engineering para estructurar la memoria de tus agentes, aplicado a la capa de datos. Y si quieres ver a dónde se te va la factura, lo desglosé en medir el consumo de tokens de un agente.


    Las cuatro operaciones

    Todo lo que necesitas hacer con Python en este contexto cae en una de estas cuatro:

      1. CARGAR   →  CSV, SQL, JSON, Parquet
      2. LIMPIAR  →  tipos, nulos, duplicados
      3. REDUCIR  →  agrupar, agregar, ordenar
      4. VALIDAR  →  esquema antes de entregar
      ─────────────────────────────────────────
      la 3 es la que decide si el agente acierta
    

    No hay una quinta. Si te encuentras entrenando un modelo, te has ido del carril.


    1 y 2. Cargar y limpiar

    Pandas carga desde casi cualquier sitio con una línea, y el 90% del trabajo de limpieza son tres cosas:

    import pandas as pd
    
    df = pd.read_csv("ventas.csv", parse_dates=["fecha"])
    
    df = df.drop_duplicates(subset=["id_pedido"])       # duplicados por clave
    df = df.dropna(subset=["curso", "precio"])          # filas sin lo esencial
    df["precio"] = pd.to_numeric(df["precio"], errors="coerce")
    

    Ese errors="coerce" es el detalle que más disgustos evita: convierte a NaN lo que no se pueda parsear en vez de reventar. Un CSV real siempre trae una celda con "29,99 €" donde esperabas un número.

    Dos cosas que muerden el primer día:

    El filtrado es una máscara booleana. df["completado"] devuelve una serie de True/False, y df[mascara] se queda con las filas donde es True. Con condiciones compuestas usa & y | con paréntesis, nunca and y or.

    Casi todo devuelve un objeto nuevo. Si haces df.drop(columns=["x"]) y no reasignas, no ha pasado nada.


    3. Reducir, que es donde está el trabajo de verdad

    groupby más agg resuelve la inmensa mayoría de las preguntas que le vas a hacer a un agente sobre tus datos:

    resumen = (df.groupby("curso")
                 .agg(ventas=("precio", "size"),
                      ingresos=("precio", "sum"),
                      tasa_completado=("completado", "mean"))
                 .round(2)
                 .reset_index())
    

    Tres detalles que importan:

    • .agg() con nombres te deja bautizar las columnas de salida. Sin eso acabas con nombres compuestos horribles y el agente los lee peor.
    • mean() sobre una columna booleana da la proporción directamente. Ahí sale el 0,27 de la tabla de arriba sin cálculo extra.
    • .reset_index() baja la clave del agrupado del índice a columna. Si se te olvida, el JSON que entregas sale con otra forma.

    La regla, si te quedas con una sola de este post: agrega hasta que la tabla quepa en una pantalla. Si no cabe, todavía no has terminado de reducir.

    Cuando el volumen crezca o prefieras SQL a encadenar métodos, tienes la alternativa sin salir del portátil en DuckDB para analizar con SQL sin exportar nada.


    4. Validar en la frontera: Pydantic es el Zod de Python

    Aquí es donde tu instinto de TypeScript te sirve tal cual. Lo que hace Zod en tu producto lo hace Pydantic en la capa de datos: defines el esquema y lo que no encaja no pasa.

    from pydantic import BaseModel, Field
    
    class ResumenCurso(BaseModel):
        curso: str
        ventas: int = Field(ge=0)
        ingresos: float = Field(ge=0)
        tasa_completado: float = Field(ge=0, le=1)
    
    filas = [ResumenCurso(**r) for r in resumen.to_dict(orient="records")]
    payload = [f.model_dump() for f in filas]
    

    Ese le=1 en tasa_completado parece una tontería y es justo el guardarraíl que quieres: si un cambio en el pipeline te deja una proporción en 1,4, prefieres que explote aquí y no que el agente construya un razonamiento entero sobre un dato imposible.

    Y esa es la diferencia de fondo con el análisis de datos clásico: un dato sucio ya no es una celda rara en un gráfico, es una alucinación con aspecto de respuesta correcta. El agente no va a dudar de lo que le des.

    Los patrones de contrato del lado TypeScript están en el curso de Zod para TypeScript, y se trasladan casi literalmente a Pydantic.


    ¿Y NumPy? Solo cuando toca

    NumPy aparece en todos los tutoriales de Python y datos, así que conviene decir cuándo lo vas a necesitar de verdad: cuando hagas aritmética sobre muchos números.

    Una lista de Python guarda punteros a objetos dispersos y obliga al intérprete a resolver el tipo en cada elemento. NumPy guarda los números en un bloque contiguo y opera en C sobre todo el bloque. Sobre un millón de valores:

    import numpy as np, timeit
    
    lista = [float(i) for i in range(1_000_000)]
    arr = np.array(lista)
    
    t_list = timeit.timeit(lambda: [x * 0.85 for x in lista], number=5) / 5
    t_np   = timeit.timeit(lambda: arr * 0.85, number=5) / 5
    print(f"lista: {t_list*1000:.1f} ms | numpy: {t_np*1000:.1f} ms | {t_list/t_np:.0f}x")
    

    En mi máquina: 102,3 ms contra 6,4 ms. Dieciséis veces. Córrelo tú, que el factor depende del hardware.

    Ahora, la parte honesta: si tu pipeline agrupa 40.000 filas una vez al día, esa diferencia no la vas a notar. Pandas ya usa NumPy por debajo. Aprende NumPy cuando el perfilado te diga que ahí está el problema, no antes.


    Cuándo NO deberías meter Python

    Añadir un lenguaje a un proyecto tiene un coste real: otro entorno, otro despliegue, otra cosa que se rompe.

    No lo metas si:

    • Son menos de unos miles de registros y ya los tienes en tu app. Un reduce en TypeScript te lo resuelve sin añadir nada.
    • Los datos ya están en tu base de datos. Un GROUP BY en SQL es más rápido y más simple que exportar, cargar en Pandas y volver.
    • Es una consulta que harás una vez. Escríbela donde te resulte más rápido y olvídala.

    Merece la pena cuando cruzas fuentes distintas (un CSV de la pasarela de pago con un export de tu base y una hoja de cálculo), cuando la limpieza tiene reglas de verdad, o cuando el paso se repite cada día. Ahí Pandas gana con claridad. Para automatizar ese paso una vez que funcione, tengo el terreno cubierto en scripts de Python para tu productividad semanal.


    El pipeline entero

    Junto, esto es todo lo que hace falta entre tu exportación y el contexto de tu agente:

    import json
    import pandas as pd
    from pydantic import BaseModel, Field
    
    class ResumenCurso(BaseModel):
        curso: str
        ventas: int = Field(ge=0)
        ingresos: float = Field(ge=0)
        tasa_completado: float = Field(ge=0, le=1)
    
    def contexto_para_agente(ruta: str) -> str:
        df = (pd.read_csv(ruta, parse_dates=["fecha"])
                .drop_duplicates(subset=["id_pedido"])
                .dropna(subset=["curso", "precio"]))
    
        resumen = (df.groupby("curso")
                     .agg(ventas=("precio", "size"),
                          ingresos=("precio", "sum"),
                          tasa_completado=("completado", "mean"))
                     .round(2)
                     .reset_index()
                     .sort_values("ingresos", ascending=False))
    
        filas = [ResumenCurso(**r) for r in resumen.to_dict(orient="records")]
        return json.dumps([f.model_dump() for f in filas], ensure_ascii=False)
    

    Treinta líneas. La salida cabe en un mensaje y ya viene validada.

    Cómo se conecta esa salida con un agente que decide y actúa sobre ella, de la idea a producción, lo enseño paso a paso en el curso Construye con IA: de la idea al producto con Claude Code.


    Qué hacer esta semana

    1. Monta el entorno con uv: uv venv y uv pip install pandas pydantic. Un segundo. (Aviso por si te lo cruzas en algún tutorial: Bun no gestiona Python, es un runtime de JavaScript; el equivalente aquí es uv.)
    2. Coge la exportación más grande que le estés pasando a un agente y mide sus caracteres. Divide entre cuatro para hacerte una idea de los tokens.
    3. Escribe el groupby que responde la pregunta que de verdad le haces, y vuelve a medir. El factor de reducción que te salga es lo que te estabas gastando de más.
    4. Ponle un esquema Pydantic a la salida antes de entregársela al modelo.

    En Dominicode Labs comparto los pipelines de datos y telemetría que uso de verdad para mirar lanzamientos y retención.

    Tu agente no necesita tus datos. Necesita la respuesta que hay dentro de ellos, y esa parte todavía la pones tú.


    Preguntas frecuentes

    ¿Por qué no le paso el CSV entero al agente y que se apañe?

    Por dos motivos. El coste, que es el menor: en el ejemplo de este post, 40.000 filas son unos 1,35 millones de caracteres —del orden de 339.000 tokens— frente a los 386 caracteres del resumen. Y el importante: un modelo con 40.000 filas delante tiene 40.000 oportunidades de fijarse en lo que no toca. Reducir no es solo ahorrar, es acotar dónde puede mirar.

    ¿Necesito saber estadística para esto?

    No. Las cuatro operaciones son cargar, limpiar, reducir y validar, y todas son programación. La estadística hace falta cuando entras en modelado predictivo, que es un problema distinto y que en la mayoría de los productos con agentes no aparece nunca.

    ¿Pandas o Polars?

    Empieza por Pandas: más documentación, más respuestas cuando te atasques y es lo que vas a encontrar en el código de otros. Polars es más rápido y su API más consistente, y compensa cuando el volumen te empiece a doler. Los conceptos —DataFrame, filtrado, agrupación— se trasladan casi enteros.

    ¿Puedo hacer esto en TypeScript y ahorrarme el Python?

    Para volúmenes pequeños, sí, y probablemente deberías: un reduce no justifica añadir un lenguaje al proyecto. Python empieza a compensar cuando cruzas fuentes distintas, cuando la limpieza tiene reglas de verdad o cuando el paso se repite a diario. Si los datos ya viven en tu base de datos, la respuesta suele ser ninguno de los dos: un GROUP BY en SQL.

    ¿Dónde pongo esta capa: en el agente o antes?

    Antes, siempre, y como un paso determinista. Si el agente tiene que cargar y agregar por su cuenta, estás usando un modelo probabilístico para hacer aritmética que un groupby resuelve exacto, más barato y sin variar entre ejecuciones. El agente debe recibir la tabla ya reducida y validada, y dedicarse a lo suyo: decidir.

  • Agentic code review: el 64,7% de los PRs se aprueba sin leerlo

    Agentic code review: el 64,7% de los PRs se aprueba sin leerlo

    Esta semana has aprobado al menos un PR sin leerlo entero. Has mirado el diff en diagonal, has visto que el CI estaba en verde y has escrito "LGTM".

    No te estoy juzgando. Te estoy describiendo. Y no lo digo yo. Lo dice un estudio sobre cinco proyectos de gran escala (Gon et al.), recogido en un paper académico sobre agentic code review que acabo de leer entero: el 64,7% de los PRs se aprueban sin un solo comentario. Y esos reviews silenciosos presentan el "LGTM smell" —aprobar sin revisar de verdad— 3,5 veces más que los reviews con conversación.

    El paper se llama Rethinking Code Review in the Age of AI: A Vision for Agentic Code Review (arXiv:2605.17548). Es un vision paper: propone un framework, no un sistema implementado. Pero la radiografía que hace del review actual es tan incómoda que he cambiado cómo revisan código mis dos herramientas open source.

    Te cuento por qué.

    Los números que describen tu equipo

    El paper recopila estudios empíricos de la última década. Léelos pensando en tu repo, no en el de otros:

    • El 34% de 333.001 descripciones de PR analizadas en GitHub estaban vacías. Ni una línea de contexto (Liu et al.).
    • El 34,3% de los PRs no enlazan con ningún issue. En commits de bugfix, el 52,4% van sin enlazar (Dogan et al.; Bachmann et al.).
    • En Mozilla, el 54% de los code reviews no detectaron bugs que estaban presentes en commits aprobados (Kononenko et al.).
    • En Microsoft, solo el 15% de los comentarios de review señalaban defectos potenciales (Czerwonka et al.). Y entre un 34,5% y un 44,47% de los comentarios se clasifican directamente como "no útiles".
    • Un 19,1% de los comentarios de review de un dataset estudiado eran, literalmente, tóxicos (Sarker et al.).

    La etapa que llamamos "control de calidad" dejó pasar bugs en más de la mitad de los reviews medidos en Mozilla, genera ruido en un tercio de los comentarios y a veces hasta hace daño.

    Y ahora métele IA.

    El code review con IA no arregla el problema. Lo desborda

    Los asistentes de IA aceleran las tareas individuales de código en más de un 50%, según los estudios que recopila el paper. Escribimos más código que nunca. Pero hay dos datos que deberían quitarte la sonrisa.

    Uno: las contribuciones generadas por IA requieren más iteraciones de review que las escritas por humanos.

    Dos: cuando la IA asiste al reviewer, este encuentra más issues de severidad baja… pero no más defectos graves. La automatización arrastra tu atención hacia los problemas fáciles. El naming, el estilo, el typo. Mientras, el bug de concurrencia pasa de largo con su "LGTM".

    El paper lo dice sin rodeos: el code review ya no es solo un cuello de botella de productividad, es "la superficie de control primaria de la calidad y la responsabilidad del código producido por IA".

    Piensa en lo que eso significa. Si un agente escribe el 60% de tu código, el review es el único punto donde un humano responde por él. Y ese punto, según los datos de arriba, está roto.

    Hay una capa del problema que el review ni siquiera puede tocar, y la desarrollé aparte en los 5 fallos del código generado por IA que un code review no puede ver. Este post va de la otra mitad: arreglar lo que el review sí puede hacer y no hace.

    Qué es el agentic code review: el review no es una etapa, es un ciclo

    El agentic code review es un modelo de revisión en el que agentes de IA especializados cubren las cinco etapas del ciclo de vida del PR, mientras el humano actúa como supervisor con capacidad de veto en cada punto de decisión. La diferencia con "un bot que comenta el diff" es que el contexto cruza las fronteras entre etapas en lugar de perderse en cada salto.

    Y esa es la propuesta central del paper: la efectividad del review no es el resultado de una etapa aislada, sino de todo el ciclo de vida del PR.

    Un comentario de review útil depende de que el PR tenga una descripción con rationale. La descripción depende de que exista un issue enlazado. Y los reviews futuros dependen de que las lecciones de los reviews pasados queden escritas en algún sitio. Ninguna herramienta que optimice una sola etapa puede resolver esas dependencias.

    El framework tiene cinco etapas con agentes especializados y puertas humanas en cada punto de decisión: PR Creation → PR Augmentation → Reviewer Selection → AI-Assisted Code Review → PR Retrospective. El reviewer deja de ser un inspector manual y pasa a ser un operador supervisor de agentes.

    De todo el framework, hay dos piezas que me parecen oro. Y son las dos que he implementado hoy.

    Qué es el veredicto de alineación: Exact, Tangling y Missing

    El paper recoge una taxonomía de Isik et al. que formaliza algo que todos intuimos pero nadie mide: ¿el PR hace lo que se pidió?

    Categoría Qué significa Cómo se manifiesta con agentes Dato del paper
    Exact Cubre lo pedido, sin extras El caso que quieres —
    Tangling Incluye código que nadie pidió Le pides un fix y refactoriza tres ficheros "de paso" 7-20% de los changesets
    Missing No cubre todo lo pedido Marca la tarea como hecha sin implementar el criterio 16,5% de los PRs
    Missing and Tangling Ambas a la vez Se deja lo pedido y añade lo que no —

    Un review que solo busca bugs responde a la pregunta equivocada. La primera pregunta no es "¿este código tiene errores?". Es "¿este código es el que se pidió?".

    Por eso el skill /ak:review de ai-workflow-kit y la fase de Code Review del plugin sdd-creator ya no cierran el review con una lista de bugs. Cuando encuentran una spec que cubre el cambio, abren el review con una capa de cumplimiento y lo cierran con un veredicto de alineación explícito, contrastado criterio a criterio contra esa spec:

    ## Review: [feature slug]
    
    Status: PASS | CHANGES REQUIRED
    Alignment: Exact | Tangling | Missing | Missing and Tangling
    
    ### Requirements compliance
    - [AC-XX]: implemented / missing / diverges — [evidence]
    - Tasks marked done without a matching implementation: [list or none]
    - Out of scope: [code no criterion asks for, or none]
    

    La regla que lo hace útil es la última: un veredicto distinto de Exact no puede ser PASS salvo que tú aceptes la desviación por escrito. El código fuera de alcance se quita o se especifica; el trabajo que falta se completa o se saca del alcance. Es un veredicto que puedes verificar en dos minutos, en lugar de un "se ve bien" que no compromete a nadie.

    Si el repo no tiene specs/, no hay contra qué contrastar y el review vuelve al formato de severidades de siempre. Que es, en sí mismo, el argumento del paper.

    Qué es la retrospectiva de PR y por qué un review sin memoria se repite

    La quinta etapa del framework es la que casi todo el mundo se salta: el PR Retrospective. Cuando el PR se aprueba o se rechaza, un agente resume qué se decidió, qué se descartó y por qué, y lo guarda en la memoria del repositorio para que los agentes (y los humanos) del siguiente review partan de ahí.

    Aquí el paper suelta un detalle que valida algo que llevo tiempo defendiendo. Al explicar por qué los modelos no generalizan entre proyectos distintos, dice que inyectar reglas específicas del repositorio vía archivos de configuración tipo "Agents.MD" directamente en la ventana de contexto del agente es una alternativa computacionalmente barata al fine-tuning. No necesitas reentrenar un modelo para que entienda tu proyecto. Necesitas escribir las decisiones en un fichero que viaje con el repo.

    Eso también lo he incorporado: los dos productos ahora cierran el review proponiendo qué promocionar a la memoria del proyecto — decisión confirmada, alternativa rechazada, riesgo que se materializó. En el flujo SDD va a specs/INDEX.md; en el kit, a memory/decisions/. Los arreglos de código se quedan en el review; solo sube el conocimiento duradero. El siguiente review no redescubre lo mismo. Acumula.

    El paper valida SDD sin saberlo

    Y hay una frase del paper que me hizo reírme solo: "el contexto debe cruzar las fronteras entre etapas". Porque eso es exactamente Spec-Driven Development: el spec.md, el plan.md y el tasks.md no se quedan en la fase de diseño. Viajan hasta el review y hasta el PR. El reviewer no reconstruye la intención desde el diff — la tiene delante, escrita antes de la primera línea de código.

    El 34% de descripciones de PR vacías no es un problema de disciplina. Es un problema de flujo: si el contexto no existe antes de codificar, nadie lo va a escribir después. SDD lo resuelve por diseño — siempre que la spec esté bien planteada, porque una spec mal escrita rompe al agente igual que no tener ninguna.

    Lo que el paper admite que puede salir mal

    No te vendo humo: los propios autores dedican una sección entera a los riesgos, y son serios.

    Las alucinaciones se propagan en cascada entre agentes. Si el agente de review inventa una vulnerabilidad de concurrencia, el agente de fixes genera locks innecesarios. Para cuando el humano detecta el error, ya has pagado los tokens de tres agentes resolviendo un problema que nunca existió.

    Súmale la degradación de contexto en PRs grandes y el sesgo de automatización: aceptar el output del agente sin verificarlo, que es el LGTM smell con esteroides.

    Y el más silencioso de todos: el deterioro del mentoring implícito. Si el chatbot le explica el PR al junior, el senior ya no se lo explica.

    La respuesta a todos esos riesgos es la misma: puertas humanas con veredictos verificables. No "confía en el agente". Tampoco "desconfía de todo". Sino: exige al agente un output que un humano pueda comprobar en minutos.

    Cómo aplicar el agentic code review hoy en 3 pasos

    No necesitas esperar a que alguien implemente el framework completo del paper. Las tres piezas con más retorno caben en tu flujo actual:

    1. Cierra cada review con un veredicto de alineación. Exact, Tangling, Missing o ambas, contra el issue o la spec. Si no puedes emitirlo, no tenías contexto para revisar — y ese es el verdadero hallazgo del review.
    2. Escribe una retrospectiva de tres líneas por PR relevante. Qué se confirmó, qué se rechazó, qué riesgo apareció. Guárdala en el repo, donde el siguiente agente la pueda leer.
    3. Haz que el contexto viaje. Spec antes del código, spec enlazada en el PR, spec delante del reviewer.

    Si además quieres que esto corra solo en cada push, ya escribí cómo integrar revisiones de código automáticas con IA en el pipeline de CI/CD — el veredicto de alineación encaja ahí como un check más.

    Y si prefieres verlo funcionando en lugar de montarlo desde cero, tanto sdd-creator como ai-workflow-kit son open source y ya incorporan las dos piezas. Si quieres montarlo guiado y de principio a fin, el curso Construye con IA recorre justo este flujo: de la spec al PR revisado. Y si lo que buscas es trabajarlo sobre proyectos completos y en directo, eso es Dominicode Labs.

    El code review no va a desaparecer. Va a convertirse en el trabajo más importante que hagas. Mejor llegar con el contexto puesto.

    Preguntas frecuentes

    ¿Qué es el agentic code review?

    Es un modelo de revisión de código en el que agentes de IA especializados cubren las cinco etapas del ciclo de vida del PR —creación, enriquecimiento, selección de reviewer, revisión y retrospectiva— mientras el humano actúa como supervisor con capacidad de veto en cada punto de decisión. La diferencia con "un bot que comenta el diff" es que el contexto cruza las fronteras entre etapas en lugar de perderse en cada salto.

    ¿Cómo emito un veredicto de alineación en un PR?

    Compara el PR contra el issue o la spec y clasifícalo en una de cuatro categorías: Exact si cubre lo pedido sin extras, Tangling si trae cambios que nadie pidió, Missing si deja algo fuera, o Missing and Tangling si ocurren ambas. Escribe la categoría explícitamente en el PR con una frase de justificación. Si no puedes clasificarlo, el problema no es el PR: es que no tenías contexto suficiente para revisarlo.

    ¿No basta con poner un agente de IA a comentar los pull requests?

    No. Cuando la IA asiste al reviewer aparecen más issues de severidad baja, pero no más defectos graves: la herramienta desplaza la atención hacia lo fácil de detectar. Y un agente que solo comenta diffs no puede saber si el PR hace lo que se pidió, porque nadie le pasó la spec ni el issue.

    ¿En qué se diferencia esto de automatizar el code review en CI/CD?

    En el alcance. Automatizar en CI/CD resuelve la ejecución: que la revisión corra sola en cada push. El enfoque agéntico resuelve el contexto: que la revisión sepa qué se pidió, quién debe revisarlo y qué se aprendió en los PRs anteriores. Son complementarios — el veredicto de alineación se puede publicar como un check más del pipeline.

    ¿El framework del paper ya se puede usar en producción?

    El framework completo no: es un vision paper, una propuesta arquitectónica sin implementación ni evaluación empírica. Pero dos de sus piezas —el veredicto de alineación y la retrospectiva escrita en el repo— no dependen de ninguna infraestructura nueva y las puedes adoptar hoy con las herramientas que ya usas.


    Referencia: Kamalı, H. Ö., Tuna, E., Haratian, V., Tüzün, E. (2026). Rethinking Code Review in the Age of AI: A Vision for Agentic Code Review. Ankara University, Microsoft y Bilkent University. arXiv:2605.17548, mayo de 2026. Vision paper — propuesta de framework, no sistema implementado.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.