Test harness para agentes de IA: el banco de pruebas que te falta en CI

test harness para agentes de IA — Dominicode

Written by

in

, , ,

Nadie prueba un motor de avión montándolo en un aparato con pasajeros. Lo amarran a un banco de pruebas, le conectan sensores, le inducen fallos y miden qué aguanta. Si revienta, revienta en tierra.

Con software tenemos el equivalente desde hace décadas y se llama test harness: el andamiaje que rodea al código bajo prueba, le inyecta entradas controladas y comprueba las salidas.

Con agentes de IA, en cambio, la mayoría probamos en caliente. Lanzamos el agente contra una API real, miramos si el resultado "parece bien" y lo damos por bueno.

El problema no es la pereza. Es que un agente rompe los tres supuestos sobre los que se construyó todo tu testing:

  • No es determinista: la misma entrada da salidas distintas.
  • Tiene efectos secundarios reales: escribe archivos, llama a APIs, toca bases de datos.
  • No tiene garantía de terminar: puede quedarse en bucle gastando dinero.

Ya expliqué por qué un LLM por sí solo no es un producto y qué capas necesita alrededor para funcionar en producción. Este post va de la otra mitad del problema, la que casi nadie monta: el arnés que se ejecuta en CI, antes del deploy. Con código.


Por qué un test unitario normal no sirve aquí

Un test clásico es un contrato de tres líneas: preparas la entrada, ejecutas, comparas con el valor esperado.

Con un agente, ese toEqual no existe. La respuesta correcta no es una cadena concreta, es cualquiera de un conjunto amplio de cadenas aceptables. Y si aun así escribes la aserción exacta, tendrás un test que pasa hoy y falla el martes sin que nadie haya tocado nada.

De ahí sale la reacción habitual, que es la equivocada: dejar de testear el agente y testear solo las funciones puras que lo rodean. Los parsers, los formateadores, los validadores. Cosas que ya sabías hacer.

Mientras tanto, lo que de verdad puede costarte dinero —el bucle, las llamadas a herramientas, el gasto— viaja a producción sin una sola comprobación.

El arnés cambia la pregunta. En lugar de "¿ha respondido lo correcto?", que es un problema de evals, pregunta cosas que sí tienen respuesta binaria:

  • ¿Ha llamado a alguna herramienta que no tenía permitida?
  • ¿Se ha pasado del presupuesto de tokens que le di?
  • ¿Ha terminado dentro del tiempo límite?
  • ¿Ha intentado escribir fuera de su directorio temporal?
  • ¿Ha llamado 14 veces a la misma herramienta con los mismos argumentos?

Eso son tests de verdad: deterministas, rápidos y rojos cuando algo se rompe.

   caso de prueba              TEST HARNESS                  veredicto
  ┌──────────────┐    ┌──────────────────────────────┐    ┌────────────┐
  │ entrada fija │───►│  tools falsas (sin red)      │───►│ PASS/FAIL  │
  │ estado fijo  │    │  presupuesto de tokens       │    │ trace.json │
  └──────────────┘    │  timeout + AbortSignal       │    └────────────┘
                      │  directorio efimero          │
                      └──────────────────────────────┘

Las 3 piezas que hacen testeable a un agente

1. Herramientas falsas, no red

La regla es simple: en modo test, el agente no toca nada real. Ni base de datos, ni API de pagos, ni sistema de archivos fuera de un directorio temporal que destruyes al terminar.

Y no basta con mockear la implementación. Hay que no exponer las herramientas no autorizadas: si el agente ve deleteUser en su lista de tools, tarde o temprano la llamará, y el error que quieres detectar en CI es precisamente ese. Un arnés que expone la herramienta y luego lanza una excepción llega tarde para razonar sobre el diseño, aunque salve los datos.

Si necesitas ejecutar código generado de verdad —no simularlo—, ahí el aislamiento sube un nivel y toca contenedor: lo conté en Docker sandboxing para ejecutar código de IA de forma segura.

2. Presupuesto de tokens y timeout que cortan de verdad

Esta es la pieza que casi todo el mundo escribe mal.

He visto docenas de arneses con un campo maxTokens en la configuración que no se comprueba en ningún sitio. Y timeouts implementados con Promise.race que devuelven el control al test pero dejan la ejecución corriendo por detrás, gastando tokens contra la API mientras el test ya ha dado verde.

Un límite que no corta no es un límite: es un comentario.

3. Traza reproducible

El arnés graba cada paso: qué herramienta, con qué argumentos, cuánto tardó, cuánto costó. Un array de objetos serializado a JSON.

Sirve para dos cosas. Para que un fallo en CI sea depurable sin volver a lanzar el agente. Y para escribir aserciones sobre el proceso, no sobre el texto final, que es donde está la señal útil: si el agente llegó al resultado correcto llamando siete veces a la misma consulta, eso es un bug aunque la salida sea perfecta.


El arnés en TypeScript

Vamos al código. Un arnés mínimo con presupuesto real, cancelación real y traza, sin dependencias más allá de Zod para validar los argumentos que el modelo envía a cada herramienta.

Primero, los tipos y el registro de herramientas:

import { z } from "zod";

export interface HarnessConfig {
  maxTokens: number;
  timeoutMs: number;
  allowedTools: string[];
}

export interface TraceEntry {
  tool: string;
  args: unknown;
  durationMs: number;
  tokens: number;
}

/** Herramienta ya validada: el schema queda encapsulado dentro de `run`. */
export interface HarnessTool {
  cost: number;
  run: (rawArgs: unknown) => Promise<unknown>;
}

export class BudgetExceededError extends Error {}

/**
 * En el punto de definicion conservas el tipado completo del schema.
 * En el registro todas las tools comparten la misma firma, que es lo
 * que permite recorrerlas en bucle sin castings.
 */
export function defineTool<S extends z.ZodType>(
  schema: S,
  cost: number,
  run: (args: z.infer<S>) => Promise<unknown>,
): HarnessTool {
  return { cost, run: (rawArgs) => run(schema.parse(rawArgs)) };
}

// Fixtures: nada de esto sale a la red.
export const testTools: Record<string, HarnessTool> = {
  queryDatabase: defineTool(
    z.object({ table: z.string(), limit: z.number().max(100) }),
    320,
    async ({ table }) => ({ rows: [{ id: 1, table, name: "Fixture User" }] }),
  ),
  sendEmail: defineTool(
    z.object({ to: z.string().email(), body: z.string() }),
    90,
    async () => ({ delivered: true }),
  ),
};

Ahora el arnés. Fíjate en tres detalles: solo se construyen las herramientas permitidas, el presupuesto se comprueba antes de ejecutar cada llamada, y el temporizador se limpia siempre.

export type HarnessStatus = "SUCCESS" | "TIMEOUT" | "BUDGET_EXCEEDED" | "FAILED";

export interface HarnessResult {
  status: HarnessStatus;
  tokensUsed: number;
  durationMs: number;
  output: string | null;
  trace: TraceEntry[];
}

type ToolBox = Record<string, (args: unknown) => Promise<unknown>>;

export async function runWithHarness(
  task: (tools: ToolBox, signal: AbortSignal) => Promise<string>,
  config: HarnessConfig,
): Promise<HarnessResult> {
  const startedAt = performance.now();
  const trace: TraceEntry[] = [];
  let tokensUsed = 0;

  // 1. Solo existen las tools autorizadas. El resto no se expone.
  const tools: ToolBox = {};
  for (const name of config.allowedTools) {
    const tool = testTools[name];
    // Un nombre desconocido es un error de configuracion del test: que reviente ya.
    if (!tool) throw new Error(`Tool desconocida en allowedTools: ${name}`);

    tools[name] = async (rawArgs: unknown) => {
      // 2. El presupuesto se comprueba ANTES de gastar.
      if (tokensUsed + tool.cost > config.maxTokens) {
        throw new BudgetExceededError(
          `Presupuesto agotado: ${tokensUsed} + ${tool.cost} > ${config.maxTokens}`,
        );
      }
      const t0 = performance.now();
      const result = await tool.run(rawArgs); // Zod valida dentro: si no cuadra, revienta
      tokensUsed += tool.cost;
      trace.push({
        tool: name,
        args: rawArgs,
        durationMs: Math.round(performance.now() - t0),
        tokens: tool.cost,
      });
      return result;
    };
  }

  // 3. Cancelacion real: la tarea recibe el signal y debe propagarlo al SDK.
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), config.timeoutMs);

  const finish = (status: HarnessStatus, output: string | null): HarnessResult => ({
    status,
    tokensUsed,
    durationMs: Math.round(performance.now() - startedAt),
    output,
    trace,
  });

  try {
    const output = await task(tools, controller.signal);
    return finish("SUCCESS", output);
  } catch (error) {
    if (controller.signal.aborted) return finish("TIMEOUT", null);
    if (error instanceof BudgetExceededError) return finish("BUDGET_EXCEEDED", null);
    return finish("FAILED", error instanceof Error ? error.message : String(error));
  } finally {
    clearTimeout(timer); // sin esto, el timer mantiene vivo el proceso al terminar
  }
}

Un aviso honesto sobre el punto 3: el AbortSignal solo cancela de verdad si tu tarea lo propaga al SDK del modelo y a cada fetch. Si lo ignoras, el arnés dará TIMEOUT y devolverá el control al test, pero la llamada seguirá viva por detrás y te la cobrarán igual. El signal no es decorativo: es el único mecanismo que corta el gasto.

Y ahora sí, un test

Con esto, probar el bucle del agente vuelve a ser testing normal:

import { describe, expect, it } from "vitest";
import { runWithHarness } from "./harness";

describe("agente de facturación", () => {
  it("corta la ejecución al agotar el presupuesto", async () => {
    const result = await runWithHarness(
      async (tools) => {
        // Un agente en bucle: consulta la misma tabla sin parar.
        for (let i = 0; i < 20; i++) {
          await tools.queryDatabase({ table: "invoices", limit: 10 });
        }
        return "listo";
      },
      { maxTokens: 1_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
    );

    expect(result.status).toBe("BUDGET_EXCEEDED");
    expect(result.tokensUsed).toBeLessThanOrEqual(1_000);
    expect(result.trace).toHaveLength(3); // 3 × 320 = 960; la cuarta no cabe
  });

  it("no expone las herramientas fuera del allowlist", async () => {
    const result = await runWithHarness(
      async (tools) => {
        if ("sendEmail" in tools) return "PELIGRO: tool disponible";
        return "ok";
      },
      { maxTokens: 5_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
    );

    expect(result.output).toBe("ok");
  });
});

Deterministas, sin red, en milisegundos. Se pueden ejecutar en cada push sin pensar en la factura.

Ese expect(result.trace).toHaveLength(3) es el tipo de aserción que solo puedes escribir si grabas la traza: comprueba el comportamiento del bucle, no el texto de salida.

Si quieres afinar el diseño de tests y el aislamiento de dependencias externas —que es exactamente el músculo que necesitas aquí—, lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library. Y el uso de Zod para validar los argumentos que envía el modelo, con transformaciones y errores tipados, lo tienes en el curso de Zod para TypeScript.


Qué encaja arriba y qué encaja abajo

Tres piezas que se confunden todo el rato y conviene separar:

Pieza Cuándo corre Qué responde
Test harness En CI, en cada push ¿Se sale de los límites, del allowlist o del tiempo?
Evals Por lotes, con casos reales ¿La calidad de las respuestas sube o baja?
Agentic harness En producción, en cada ejecución ¿Cómo lo mantengo controlado con usuarios reales?

El arnés de pruebas es el más barato de los tres y el que casi nadie tiene. Cuestión de horas montarlo, y atrapa la clase de fallo que más caro sale.

Sobre el reparto de trabajo entre los tests que escribes tú y los que genera el agente, ya hay un post entero: adopta TDD para implementar pruebas efectivas con agentes de IA. Y sobre por qué la spec y la arquitectura no bastan sin esta capa debajo, también. Este post es la parte que faltaba: el código.

Si trabajas con Spec-Driven Development, el encaje es directo. Los límites que escribes en la sección de NFRs del spec.md —presupuesto, latencia, herramientas permitidas— dejan de ser un párrafo y pasan a ser los argumentos de HarnessConfig. La especificación se vuelve ejecutable, que es de lo que va el libro de Spec-Driven Development.


Lo que puedes montar esta semana

  1. Una lista blanca de herramientas por entorno. Que en test solo existan las que necesita el caso.
  2. Un presupuesto que corte. Comprobado antes de cada llamada, no después. Si tu maxTokens no aparece en ningún if, no existe.
  3. Una traza en JSON por ejecución. Y al menos un test que asierte sobre ella, no sobre el texto de salida.

En Dominicode Labs montamos este tipo de arneses sobre agentes que corren horas sin supervisión.

Deja de probar tus motores en pleno vuelo. Amárralos al banco, súbeles la presión hasta que rompan y arréglalos en tierra, que es donde sale barato.


Preguntas frecuentes

¿Cómo se testea algo que no es determinista?

No asertando sobre el texto de salida, sino sobre el comportamiento observable: qué herramientas llamó, con qué argumentos, cuántas veces, cuánto gastó y si terminó a tiempo. Todo eso sí es determinista y da un rojo claro cuando se rompe. La calidad de la respuesta es otra disciplina y se mide por lotes, no en cada push.

¿El test harness sustituye a los mocks de toda la vida?

No, los usa. La diferencia es el alcance: un mock reemplaza una dependencia concreta, mientras que el arnés controla el entorno completo de la ejecución —qué herramientas existen, cuánto puede gastar, cuánto puede tardar y qué queda grabado—. Un mock por sí solo no impide que el agente entre en bucle.

¿Hay que llamar al modelo real en estos tests?

No en los que corren en cada push: se ejecuta el bucle del agente con respuestas fijas, y eso vale para verificar límites, allowlist y control de flujo. Las ejecuciones con modelo real cuestan dinero y tardan, así que van en un job aparte, programado y sobre un conjunto reducido de casos.

¿Qué hago si el timeout salta pero el agente sigue gastando dinero?

Es que estás cortando en el sitio equivocado. Promise.race devuelve el control al test pero no cancela nada: hay que crear un AbortController, pasar su signal a la tarea y propagarlo al SDK del modelo y a cada fetch. Si el SDK que usas no acepta señal de cancelación, el único corte real es aislar la ejecución en un proceso o contenedor aparte y matarlo.

¿Merece la pena montarlo si mi agente solo lee datos?

Sí, por el gasto y por los bucles. Un agente de solo lectura no borra nada, pero puede repetir la misma consulta cuarenta veces y facturarte la broma entera. El presupuesto y la traza detectan ese patrón en CI, que es donde cuesta cero arreglarlo.


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

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *